From 09a4cf2eb3850784bf51f5828d5a09161e22f2e0 Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni Date: Tue, 21 Jul 2026 00:18:10 +0530 Subject: [PATCH 01/14] spec: csv-analyst full spec (roadmap/architecture/agent/data/api/ui/capabilities) --- spec/agent.md | 276 ++++++++++++++----------------- spec/api.md | 137 +++++++++++++-- spec/architecture.md | 143 +++++++++++----- spec/capabilities/csv-analyst.md | 56 +++++++ spec/capabilities/index.md | 39 +---- spec/data.md | 138 +++++++++++++--- spec/roadmap.md | 90 ++++++---- spec/ui.md | 72 ++++++-- 8 files changed, 629 insertions(+), 322 deletions(-) create mode 100644 spec/capabilities/csv-analyst.md 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. From d82badb490744297d7fbf9fa4e5d68c94010c299 Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni Date: Tue, 21 Jul 2026 00:37:43 +0530 Subject: [PATCH 02/14] harness v0.3.1: optimize the build loop for step-3.7-class models (gates over prose) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forensic mining of all Jul-20 runs (5 lenses, 44 findings) showed step-3.7's output is clean and its question content the best of any model — it fails only where the SKILL relies on prose it under-executes, and on the nvidia serving route (240s dead-streams = 43% of agent-active time, zero prompt cache). hy3's wins trace to endpoint reliability (0 429s / 2,613 calls) + clean turn economy. Changes (all: convert prose to numbered gates / verbatim blocks): - Stage 1: five numbered intake mechanics rules — batch each round as parallel clarify calls in ONE turn (18m10s -> ~10m measured), flat string choices (3/20 calls silently dropped options), no SKILL-text leak, round-exit question-count check (Round 3 dropped 2/4), recap habit. - API key: promoted from prose to a numbered HARD GATE — Stage 2 is invalid until the transcript contains the validation script's OK (live run skipped it entirely and built Phase 1 on an unvalidated key). - Stage 2 scaffold: verbatim script block (base captured BEFORE branching; live run invented `rev-parse HEAD~2` and PR'd against main), mandatory git.md read, NOTES.md in the first commit, and a fresh-clone guard: when base == default branch, open the PR as DRAFT titled [build — DO NOT MERGE]. - Stage 2->3 seam: after `gh pr create`, IMMEDIATELY start Phase 1 — no permission stop (un-asked pause idled a live run 72 minutes). - Stage 3: numbered slice loop (ONE slice -> tests -> implement -> gate), never "all surfaces in one pass" (live run shipped 11 type errors, zero tests). - Router (.hermes.md + AGENTS.md): a build-shaped message IS $ARGUMENTS — start intake same turn, never bounce the idea back (live run wasted a round-trip). - Pitfall #21: serving route matters as much as the model (nvidia vs OpenRouter step-3.7: 0% vs 83.6% cache); reliability beats latency; gates over prose; sessions.model lies (attribute via per-call log lines). Co-Authored-By: Claude Fable 5 --- .hermes.md | 6 ++ AGENTS.md | 8 +- harness/skills/zero-shot-build/SKILL.md | 81 ++++++++++++++++--- .../references/hermes-pitfalls.md | 22 +++++ 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/.hermes.md b/.hermes.md index f41aadd..dd52dae 100644 --- a/.hermes.md +++ b/.hermes.md @@ -67,6 +67,12 @@ start to finish.** The `SKILL.md` IS the process — never improvise your own bu **Routing rules:** - The outcome is ALWAYS one of the three skills above — never a fourth action, never an ad-hoc build outside a SKILL. Reading and following the chosen `SKILL.md` is mandatory. +- **A build-shaped message IS the argument.** If the user's message contains an idea + ("build/make me an agent that…"), that text **is** `$ARGUMENTS` — read the SKILL and + **start intake in this same turn**. NEVER reply by asking the user to re-submit the idea, + paste it again, or invoke the skill themselves (the skills set + `disable-model-invocation` — YOU are the only one who can start them; a live run bounced + the user's perfectly good idea back and wasted a round-trip). - **Nothing built yet** (spec still has ``, bare baseline) → default to **build**. - **Genuinely ambiguous** between build / fix / sync → ask ONE short clarifying question, diff --git a/AGENTS.md b/AGENTS.md index d76aada..d7a3396 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,9 +81,11 @@ process; never improvise one): - **sync** — reconcile spec ↔ code (spec wins) → `harness/skills/zero-shot-sync/SKILL.md` The outcome is always one of these three — nothing built yet ⇒ default to **build**; -genuinely ambiguous ⇒ ask one short clarifying question first. (`.hermes.md` holds the -authoritative router. The `/zero-shot-*` slash commands are optional and require registering -this clone via `skills.external_dirs`.) +genuinely ambiguous ⇒ ask one short clarifying question first. **A build-shaped message IS +the argument: start intake in that same turn — never ask the user to re-submit the idea or +invoke the skill themselves** (`disable-model-invocation` means only you can start it). +(`.hermes.md` holds the authoritative router. The `/zero-shot-*` slash commands are optional +and require registering this clone via `skills.external_dirs`.) | Skill | Purpose | |-------|---------| diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 0142036..7df4bde 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -69,7 +69,24 @@ Intake has **two fixed sections and a variable middle**: 2. **Technical round (fixed, always last)** — build-blockers only (LLM provider, stack, access method). -All rounds use `clarify`. Three resilience rules: +All rounds use `clarify`. **Five mechanics rules (numbered — follow exactly):** + +1. **BATCH each round.** Issue ALL of a round's questions as PARALLEL `clarify` calls in ONE + assistant turn — exactly one question + one flat `choices` array per call. Never one call + per turn (a 20-question intake as 20 sequential round-trips doubles intake time and + re-sends the full context each turn); never multiple questions crammed into one call. +2. **CHOICES SHAPE.** `choices` is a flat array of plain strings, 4–6 options, no nested + arrays/objects, no duplicates. A malformed entry silently drops the option — the user + never sees it. +3. **NO SKILL-TEXT LEAK.** Never copy this file's headings or bullets ("4 questions, all + multiSelect", "Tailor options to…") into user-facing question text. The user sees only + natural questions about THEIR idea. +4. **ROUND-EXIT CHECK.** Before leaving a round, count: did you ask every dimension listed + for that round (or note it as already answered)? Dropping a listed question is a failure. +5. **RECAP.** Open each round with a 1–2 sentence recap of what the prior answers + established, then the new questions. + +Three resilience rules: - **If `clarify` fails DURING a round** (mid-round timeout, empty result, unavailable), fall back to plain text for the REMAINING rounds only — ask one question at a time, wait for the @@ -160,7 +177,18 @@ spec-writer fill every capability file without a single guess. - **How will they access it?** — Web UI, CLI, REST API, scheduled job. - **One follow-up** only if something would force a mid-build pause. -**API key** (the only manual user step). **`.env` is a secret-bearing file — Hermes's +**API-KEY GATE (numbered, mandatory — this is a HARD GATE, not advice):** + +1. The moment the technical round's answers land, run the `.env` validation script below via + `execute_code`/`terminal` — in THIS turn, before any spec work. +2. The script must print `PRESENT` (boolean presence) **and** `OK` (one minimal real API + call succeeded). Anything else → escalate to the user with the exact reason and re-run + after they fix `.env`. +3. **Stage 2 is INVALID until this session's transcript contains the script's `OK` output.** + A live run skipped this check (it was prose, not a gate) and built an entire phase on an + unvalidated key. Do not be that run. + +**`.env` is a secret-bearing file — Hermes's `read_file` tool hard-blocks it outright ("Access denied: ... secret-bearing environment file"). Never call `read_file` on `.env` — a live run hit this and, instead of working around it, asked the user to manually open the file and confirm, 10 minutes into intake.** @@ -197,27 +225,58 @@ explicitly. ("Just build it" → narrow MVP, baseline defaults, documented as as tests it). **Verify on handback**: no `` left, every phase has a runnable gate, `spec/agent.md` exists if a framework is chosen. Surface its `Assumed:` flags to the user in your next message (don't wait on them). -2. **SCAFFOLD** — you own git (`harness/rules/git.md`): +2. **SCAFFOLD** — you own git. **First read `harness/rules/git.md` (actually read it — a + live run skipped it and invented its own git flow).** Then: - **Clean-baseline precheck (do this FIRST).** A fresh build must start from untouched boilerplate: confirm `spec/` still has `` markers AND no app/agent output dir already exists. If either is already populated, you are on a PRIOR build's branch — STOP and confirm with the user before continuing. (A live run inherited an old ASP.NET+MSSQL data-analyst spec this way and tried `dotnet`/Docker on a Python box.) - - `base=$(git rev-parse --abbrev-ref HEAD)` — capture `` BEFORE branching; never - `git checkout main` first (you dogfood the harness version you are on). - - `name="feature/-$(date +%Y%m%d-%H%M)-v0.1"` — the date-time slug makes it unique. - Before creating it, `git ls-remote --heads origin "$name"`; if it somehow exists, bump - the timestamp. **Never `git checkout` an existing feature branch to build into** — that - imports the prior build's stack. Then `git checkout -b "$name"`. + - **Run this script VERBATIM** (adapt only ``) — do not re-derive any step. A live + run improvised `git rev-parse --abbrev-ref HEAD~2` (meaningless), silently fell back to + the forbidden literal `main`, and opened its PR against `main`: + + ```bash + base=$(git rev-parse --abbrev-ref HEAD) # BEFORE branching — this is + name="feature/-$(date +%Y%m%d-%H%M)-v0.1" # date-time slug = unique + git ls-remote --heads origin "$name" # must be empty; else bump timestamp + git checkout -b "$name" + printf '# Build journal — %s\n\n' "$(date -u +%FT%TZ)" > NOTES.md + # ... stage spec/ + NOTES.md explicitly, commit, push ... + git push -u origin "$name" + if [ "$base" = "$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name)" ]; then + gh pr create --draft --base "$base" --head "$name" \ + --title "[build — DO NOT MERGE] " # fresh clone: base IS the default + else # branch; a build must NEVER merge + gh pr create --base "$base" --head "$name" # into it, so open as DRAFT + fi + ``` - The baseline in `src/` IS the scaffold — generators extend it in place (rename the capability slot, never copy beside it). Update `.env.example` for any new env vars. - - First commit + push, then open the PR immediately: `gh pr create --base "$base"` — - **never `--base main`**. `main` is boilerplate-only, ABSOLUTELY. + - `NOTES.md` is the build journal (see "The build journal" below) — it goes into the + FIRST commit and gets a timestamped entry at every friction point. +3. **PROCEED — no permission stop.** The moment `gh pr create` returns a URL, **IMMEDIATELY + begin Stage 3 Phase 1 in the same turn.** Do NOT ask the user "shall I proceed?" — the + SKILL has no gate here; the next human touchpoint is the Stage 4 testing gate after + Phase 1 is built and serving. (A live run paused here un-asked and idled 72 minutes.) ## Stage 3 — Build one phase (the loop) For the current phase (Phase 1 first; later phases on user approval): +**The slice loop is numbered — run it per slice, never "all surfaces in one pass":** + +1. Pick **ONE** slice from `spec/roadmap.md`. +2. Write that slice's **TESTS first**. +3. Implement the slice. +4. Run the slice gate (`py_compile` + `pytest --collect-only` minimum; the real gate if + logic changed). Fix until green, THEN take the next slice. + +Never announce "writing all backend surfaces, prompts, and frontend in one pass" — a live +run did exactly that and shipped 11 type errors on its first write with zero test files. + +Details per step: + 1. **Read the phase's slices** from `spec/roadmap.md`. 2. **Implement each slice** via the **code-generator** role — delegate independent slices in parallel (up to 3) when `delegate_task` works AND the LLM key is a paid/dedicated one; diff --git a/harness/skills/zero-shot-build/references/hermes-pitfalls.md b/harness/skills/zero-shot-build/references/hermes-pitfalls.md index 196c05b..93dbc2c 100644 --- a/harness/skills/zero-shot-build/references/hermes-pitfalls.md +++ b/harness/skills/zero-shot-build/references/hermes-pitfalls.md @@ -175,3 +175,25 @@ Evidence counts are from `~/.hermes/logs/agent.log*` across Jul 10–20 builds. toolchain exists (`uv`, `.venv/bin/python`); there is no `claude`/`dotnet`/`docker` unless the spec's stack installed it. Commit or stash before any `git checkout` — a dirty tree blocks the switch ("local changes would be overwritten by checkout"). + +### 21. The SERVING ROUTE matters as much as the model; and models obey gates, not prose +From a forensic comparison of step-3.7-flash vs hy3 across all Jul-20 builds: +- **Same model, different route, different build.** `stepfun-ai/step-3.7-flash` (nvidia + route): 240 s dead-stream stalls = 43% of agent-active time, ZERO prompt caching (1.87M + tokens re-prefilled in 33 min), recurring 429s, ~60 tok/s. The *identical* model as + `stepfun/step-3.7-flash` (OpenRouter route): 13/13 clean calls, 83.6% cache hit, ~100 + tok/s. Before blaming a model, check the provider/base_url in the logs — and prefer the + route with prompt caching for long agentic sessions. +- **Reliability beats latency for unattended builds.** hy3's "magic" was measured as 0 rate + limit errors across 2,613 main-loop calls + 83% clean turn completion — that's what + survives a 9.5 h overnight build. A faster model that 429s or dead-streams loses more + wall-clock than its speed saves. +- **Prose instructions get skipped; numbered gates get followed.** The same model that + ignored a prose paragraph (API-key validation) complied exactly with numbered checklists + and reacted immediately to LSP diagnostics. When a step is mandatory, write it as a + numbered HARD GATE with an explicit "invalid until X appears in the transcript" clause, + and give git/shell sequences as VERBATIM script blocks — models re-derive prose steps + badly (one invented `git rev-parse --abbrev-ref HEAD~2` and PR'd against `main`). +- **Session `model` metadata lies.** Hermes's `sessions.model` records only the LAST model + used; mid-session switches mis-attribute entire builds. Attribute work only via per-call + `API call #N: model=` log lines. From 5af3de6e6bdbec247026a21005ffe77765f2ba73 Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 01:00:17 +0530 Subject: [PATCH 03/14] harness v0.3.1 addendum: nvidia-locked refinements from 25 more live minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User constraint update: OpenRouter step-3.7 is not free — nvidia route is forced, so the route-switch item is replaced by CONTEXT DIET as the primary transport mitigation. New live evidence (session 66c652, 00:30-00:55): - 3rd dead-stream (300s at ~118k ctx); zero cache confirmed on all 132 calls; 11.6M input tokens re-prefilled. Stalls correlate with large-context requests. - Phase 1 ran 35+ min with 10 source files on disk but ZERO commits — invisible in git/PR and one crash from loss. Slice loop now has step 5: commit+push the slice AND post a one-line progress note (the user sent four "are you done?" nudges; each answer cost a full ~135k re-prefill). - Refined delegation rule: parallel fan-out stays forbidden on shared keys, but sequential DELEGATION beats inline once root context exceeds ~60k on a no-cache route (fresh ~20k worker context ≈ 7x cheaper per call, below the stall zone). - Pitfall #21 extended: context-diet playbook + discovery that Hermes's background skill-review runs in-session on the MAIN model with full context (own call counter at 135-150k on the same endpoint, amplifying 429s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- harness/skills/zero-shot-build/SKILL.md | 22 +++++++++++++++---- .../references/hermes-pitfalls.md | 11 ++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 7df4bde..c91c5f1 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -270,19 +270,33 @@ For the current phase (Phase 1 first; later phases on user approval): 2. Write that slice's **TESTS first**. 3. Implement the slice. 4. Run the slice gate (`py_compile` + `pytest --collect-only` minimum; the real gate if - logic changed). Fix until green, THEN take the next slice. + logic changed). Fix until green. +5. **Commit + push the slice** (`git commit -m "phase-N: <slice>" && git push`) and post a + **one-line progress note** to the user: `Slice 2/5 done — <what>, gate green; next: + <what>`. THEN take the next slice. Never announce "writing all backend surfaces, prompts, and frontend in one pass" — a live run did exactly that and shipped 11 type errors on its first write with zero test files. +Steps 5's two lines are cheap and load-bearing: a live run sat 35+ minutes with 19 dirty +files and zero commits on a stall-prone endpoint (one crash from losing it all), and its +silence drew four "are you done?" nudges from the user — each costing a full-context +re-prefill to answer. Narrate at slice boundaries and nobody has to ask. Details per step: 1. **Read the phase's slices** from `spec/roadmap.md`. 2. **Implement each slice** via the **code-generator** role — delegate independent slices in parallel (up to 3) when `delegate_task` works AND the LLM key is a paid/dedicated one; - otherwise inline, sequentially, one slice at a time. **On a shared/free key, prefer - sequential inline** — parallel fan-out multiplies 429s on one credential pool and stalls - the build (mining the prior runs showed ~14h cumulative blocked on pool exhaustion). + otherwise sequentially, one slice at a time. **Parallel fan-out on a shared/free key is + always forbidden** — it multiplies 429s on one credential pool and stalls the build + (mining the prior runs showed ~14h cumulative blocked on pool exhaustion). Sequential + mode has two sub-modes — pick by CONTEXT SIZE, not by habit: + - **Root context lean (< ~60k tokens)** → work inline; delegation overhead isn't worth it. + - **Root context heavy (> ~60k) on a NO-CACHE route** → delegate slices **sequentially** + (one worker at a time). Every inline call re-prefills the whole root conversation + (a live run re-sent 11.6M input tokens across 132 calls at ~135k each); a worker runs + the slice in a fresh ~20k context — ~7× cheaper per call, faster, and below the + large-context stall zone (live dead-streams occurred at 60k+ and 118k ctx). Verify each handback's CONTENT, not just its status — a worker can return `status=completed` whose body is a rate-limit error; that slice is NOT done. Each slice = its surfaces + its tests, test-first. Tell each generator exactly which files it owns; slices own disjoint paths. diff --git a/harness/skills/zero-shot-build/references/hermes-pitfalls.md b/harness/skills/zero-shot-build/references/hermes-pitfalls.md index 93dbc2c..436b6f7 100644 --- a/harness/skills/zero-shot-build/references/hermes-pitfalls.md +++ b/harness/skills/zero-shot-build/references/hermes-pitfalls.md @@ -197,3 +197,14 @@ From a forensic comparison of step-3.7-flash vs hy3 across all Jul-20 builds: - **Session `model` metadata lies.** Hermes's `sessions.model` records only the LAST model used; mid-session switches mis-attribute entire builds. Attribute work only via per-call `API call #N: model=` log lines. +- **Locked to a no-cache route (e.g. free-tier-only)? The lever is CONTEXT DIET, not the + route.** Live measurement: 11.6M input tokens re-prefilled across 132 calls at ~135k + each; all dead-stream stalls hit on large-context requests (60k+/118k). Mitigations, in + order: (1) commit+push per slice — never let 35 min of work sit uncommitted on a + stall-prone endpoint; (2) once root context exceeds ~60k, delegate slices sequentially + into fresh ~20k worker contexts instead of building inline; (3) narrate at slice + boundaries so the user never has to ask "are you done?" — every nudge-and-answer costs a + full-context re-prefill; (4) know that Hermes's background skill-review runs IN-SESSION + on the MAIN model with the full context (observed: its own call counter at 135→150k + tokens on the same endpoint, amplifying 429s) — route it to the aux model if the config + allows, and account for it when reading rate-limit noise. From 183a6833178e93891f09edc6cda3973f39a2d1ea Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 01:21:36 +0530 Subject: [PATCH 04/14] phase-1 wip --- pyproject.toml | 2 + src/api/__init__.py | 3 +- src/api/csv_api.py | 62 +++++++++++++++++++++ src/capabilities/agent.py | 37 +++++++++++++ src/capabilities/csv_nodes.py | 89 ++++++++++++++++++++++++++++++ src/capabilities/csv_state.py | 21 +++++++ src/capabilities/tasks.py | 14 +++++ src/config/settings.py | 33 +++++++---- src/db/models.py | 44 ++++++++++++++- src/domain/__init__.py | 15 ++++- src/domain/session.py | 39 +++++++++++++ src/graph/runner.py | 7 +-- src/graph/state.py | 28 ++++++++-- src/prompts/__init__.py | 71 ++++++++++++++++++++++++ src/prompts/csv_explain.md | 1 + src/prompts/csv_plan.md | 1 + src/prompts/csv_query.md | 1 + src/services/csv_exec.py | 33 +++++++++++ src/services/query_exec.py | 25 +++++++++ src/services/sessions.py | 100 ++++++++++++++++++++++++++++++++++ 20 files changed, 602 insertions(+), 24 deletions(-) create mode 100644 src/api/csv_api.py create mode 100644 src/capabilities/agent.py create mode 100644 src/capabilities/csv_nodes.py create mode 100644 src/capabilities/csv_state.py create mode 100644 src/capabilities/tasks.py create mode 100644 src/domain/session.py create mode 100644 src/prompts/__init__.py create mode 100644 src/prompts/csv_explain.md create mode 100644 src/prompts/csv_plan.md create mode 100644 src/prompts/csv_query.md create mode 100644 src/services/csv_exec.py create mode 100644 src/services/query_exec.py create mode 100644 src/services/sessions.py diff --git a/pyproject.toml b/pyproject.toml index 10c0d05..0e3be08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ "langgraph>=0.2.28", "httpx>=0.27", "structlog>=24.1", + "duckdb>=1.0", + "pyodbc>=5.0", ] [dependency-groups] 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..1e4eae1 --- /dev/null +++ b/src/api/csv_api.py @@ -0,0 +1,62 @@ +"""CSV upload and run endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, 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(session: Session = Depends(get_session)) -> dict: + row = create_session() + meta = schema_summary(row.id) + return ok({ + "id": row.id, + "status": row.status, + "created_at": row.created_at.isoformat(), + "schema_summary": meta, + }) + + +@router.get("/sessions/{session_id}") +def get_session_endpoint(session_id: str, session: Session = Depends(get_session)) -> 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.isoformat(), + "updated_at": row.updated_at.isoformat(), + "schema_summary": meta, + }) + + +@router.post("/sessions/{session_id}/csv") +async def upload_csv(session_id: str, files: list[UploadFile] = File(...), session: Session = Depends(get_session)) -> 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"]), + }) diff --git a/src/capabilities/agent.py b/src/capabilities/agent.py new file mode 100644 index 0000000..6e07f91 --- /dev/null +++ b/src/capabilities/agent.py @@ -0,0 +1,37 @@ +"""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_query", "csv_execute") + graph.add_edge("csv_execute", "csv_explain") + graph.add_edge("csv_explain", "csv_finalize") + graph.add_edge("csv_error", END) + graph.add_edge("csv_finalize", END) + + def route_after_query(state): + if state.get("error"): + return "csv_error" + return END + + graph.add_conditional_edges("csv_query", route_after_query, {"csv_error": "csv_error"}) + return graph.compile() + + +agentic_ai = build_agent() diff --git a/src/capabilities/csv_nodes.py b/src/capabilities/csv_nodes.py new file mode 100644 index 0000000..9de0c6b --- /dev/null +++ b/src/capabilities/csv_nodes.py @@ -0,0 +1,89 @@ +"""CSV Analyst graph nodes.""" +from __future__ import annotations + +import hashlib +import json +import time + +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"), + "history": state.get("conversation_history", [])[-8:], + "sample_size": 3, + }) + out = client.complete(system, user, max_tokens=1024) + 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: + sql = (state.get("generated_code") or "").strip() + session_id = state.get("session_id") + if not sql: + raise ValueError("Empty generated query.") + if not session_id: + raise ValueError("Missing session_id.") + rows, latency_ms, result_hash = execute_sql(str(session_id), sql) + return { + "rows": rows, + "row_count": len(rows), + "latency_ms": latency_ms, + "result_hash": result_hash, + "source": "duckdb", + "error": None, + } + except Exception as exc: + return {"error": str(exc), "generated_code": state.get("generated_code")} + + +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"), + "code": state.get("generated_code"), + "rows": (state.get("rows") or [])[:200], + "row_count": state.get("row_count"), + "latency_ms": state.get("latency_ms"), + "result_hash": state.get("result_hash"), + }) + 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")} 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..721c87d --- /dev/null +++ b/src/capabilities/tasks.py @@ -0,0 +1,14 @@ +"""CSV Analyst tasks.""" +from __future__ import annotations + +from src.capabilities.agent import agentic_ai +from src.capabilities.csv_state import CsvAgentState + + +def run_csv_agent(session_id, input_text): + state: CsvAgentState = { + "session_id": session_id, + "input_text": input_text, + } + result = agentic_ai.invoke(state) + return result 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/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: <N> + - latency_ms: <X.XX> + +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..93fc31d --- /dev/null +++ b/src/services/sessions.py @@ -0,0 +1,100 @@ +"""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) + return row + + +def get_session_row(session_id: str) -> SessionRow | None: + with create_db_session() as db: + return db.get(SessionRow, session_id) + + +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()) + col_defs = ", ".join(f'"{c}" TEXT' for c in cols) + 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": []} From 5d912545749ab1a9e2c68560438edccd66ef090a Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 01:24:43 +0530 Subject: [PATCH 05/14] =?UTF-8?q?harness=20v0.3.1:=20provider-hiccup=20pro?= =?UTF-8?q?tocol=20=E2=80=94=20repo-only=20resilience,=20no=20Hermes=20con?= =?UTF-8?q?fig=20edits=20assumed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clone-and-go philosophy: users never edit ~/.hermes config, so 429/stall resilience lives in the SKILL: set the 'just type continue' expectation at build start, resume-don't-replan on continue, and structural defenses (batched turns, per-slice commit+push, state on disk). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- harness/skills/zero-shot-build/SKILL.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index c91c5f1..0fcdf50 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -58,6 +58,21 @@ Never claim, always observe: a gate "passes" only when you ran the exact command its real output tail this session. Cheap re-verification (`py_compile` + collect-only) after mechanical edits; the full real-key gate after logic changes and before any handoff. +**Provider-hiccup protocol (free tiers 429/stall; the harness assumes NO Hermes config +changes — resilience lives here):** + +- **At build start, set the expectation in ONE line:** "If I ever stop with a provider/rate + error, just type `continue` — progress is committed as I go, nothing is lost." The user + should never have to wonder whether a dead turn ate their build. +- **On `continue` after a killed turn: resume, don't re-plan.** Re-read `git status` + + `NOTES.md`, find the current slice, and pick up exactly where it stopped. No re-reading + the whole spec, no fresh plan, no apology paragraph — one line ("resuming slice N") and + back to work. +- **Your defenses against hiccups are structural, not reactive:** batch tool calls into + fewer, fuller turns (each turn is one API request — fewer requests, fewer 429s), commit + + push every slice, and keep all state in files (spec, NOTES.md, git) so any resumed turn + can reconstruct the world from disk. + ## Stage 1 — Intake (the only interactive setup step) Intake has **two fixed sections and a variable middle**: From a83d24530b685cb651b36cc031624ba48a74a37b Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 01:27:50 +0530 Subject: [PATCH 06/14] =?UTF-8?q?harness=20v0.3.1:=20de-meta=20pass=20?= =?UTF-8?q?=E2=80=94=20SKILL=20carries=20instructions,=20not=20war=20stori?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip run anecdotes/measurements from SKILL.md (they live in commit messages, PR #24, and the pitfalls reference). Net: leaner injected context. Also fold the provider-hiccup guidance into one rule and add turn-pacing: do a slice's related writes as parallel tool calls in one turn — fewer, fuller turns = fewer per-minute rate-limit hits at the same throughput. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- harness/skills/zero-shot-build/SKILL.md | 72 +++++++++++-------------- 1 file changed, 31 insertions(+), 41 deletions(-) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 0fcdf50..bfdb114 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -200,29 +200,21 @@ spec-writer fill every capability file without a single guess. call succeeded). Anything else → escalate to the user with the exact reason and re-run after they fix `.env`. 3. **Stage 2 is INVALID until this session's transcript contains the script's `OK` output.** - A live run skipped this check (it was prose, not a gate) and built an entire phase on an - unvalidated key. Do not be that run. - -**`.env` is a secret-bearing file — Hermes's -`read_file` tool hard-blocks it outright ("Access denied: ... secret-bearing environment -file"). Never call `read_file` on `.env` — a live run hit this and, instead of working -around it, asked the user to manually open the file and confirm, 10 minutes into intake.** -Instead run a `terminal`/`execute_code` script that loads `.env` itself (`python-dotenv`, -or `source .env` in bash) and prints ONLY a pass/fail signal — presence as a boolean for -the chosen provider's var (`AGENT_ANTHROPIC_API_KEY`, `AGENT_GEMINI_API_KEY`, -`AGENT_OPENROUTER_API_KEY`; for **Other**, ask which env var + base URL) — never the value -itself. **Use an ABSOLUTE path to `.env`, never a relative one.** `execute_code` runs the -script in its own sandboxed process, not the repo's working directory — `dotenv_values(".env")` -resolves against the sandbox and silently reports MISSING even when the key is genuinely -present in the repo (confirmed on a live run: the key was present, the relative-path script -still said MISSING). Resolve the repo root first (e.g. from a known file's path, or have the -`terminal` tool `pwd`/`git rev-parse --show-toplevel` and pass that in), then -`dotenv_values(f"{repo_root}/.env")`. Present → **validate it works** in that same script: one minimal real API call -(e.g. the provider's cheapest endpoint), printing only `OK` or the error type -(`401`/`429`/`model_not_found`) — a key can be *present but dead* (revoked account, expired -trial, dead model slug), and discovering that mid-build wastes a phase. Missing, or the test -call fails → tell the user the specific reason and ask them to fix `.env` (from -`.env.example`), then re-run the check. Never echo, print, or commit a key value. + +How to check `.env` (it is secret-bearing; `read_file` on it is hard-blocked — never call +it, and never ask the user to check the file themselves): run a `terminal`/`execute_code` +script that loads `.env` itself (`python-dotenv`, or `source .env` in bash) and prints ONLY +a pass/fail signal — presence as a boolean for the chosen provider's var +(`AGENT_ANTHROPIC_API_KEY`, `AGENT_GEMINI_API_KEY`, `AGENT_OPENROUTER_API_KEY`; for +**Other**, ask which env var + base URL) — never the value itself. **Use an ABSOLUTE path +to `.env`** — `execute_code` runs in its own sandbox, so a relative `dotenv_values(".env")` +reads the wrong directory and falsely reports MISSING. Resolve the repo root first +(`git rev-parse --show-toplevel` via `terminal`), then `dotenv_values(f"{repo_root}/.env")`. +Present → **validate it works** in the same script: one minimal real API call, printing +only `OK` or the error type (`401`/`429`/`model_not_found`) — a key can be present but +dead. Missing, or the test call fails → tell the user the specific reason and ask them to +fix `.env` (from `.env.example`), then re-run the check. Never echo, print, or commit a +key value. **Synthesis brief**: 2–3 paragraphs covering: what the agent does and who uses it; the interaction model (session shape, memory, multi-item); key capabilities (depth, outputs, @@ -240,16 +232,14 @@ explicitly. ("Just build it" → narrow MVP, baseline defaults, documented as as tests it). **Verify on handback**: no `<!-- FILL IN -->` left, every phase has a runnable gate, `spec/agent.md` exists if a framework is chosen. Surface its `Assumed:` flags to the user in your next message (don't wait on them). -2. **SCAFFOLD** — you own git. **First read `harness/rules/git.md` (actually read it — a - live run skipped it and invented its own git flow).** Then: +2. **SCAFFOLD** — you own git. **First read `harness/rules/git.md`.** Then: - **Clean-baseline precheck (do this FIRST).** A fresh build must start from untouched boilerplate: confirm `spec/` still has `<!-- FILL IN -->` markers AND no app/agent output dir already exists. If either is already populated, you are on a PRIOR build's - branch — STOP and confirm with the user before continuing. (A live run inherited an old - ASP.NET+MSSQL data-analyst spec this way and tried `dotnet`/Docker on a Python box.) - - **Run this script VERBATIM** (adapt only `<slug>`) — do not re-derive any step. A live - run improvised `git rev-parse --abbrev-ref HEAD~2` (meaningless), silently fell back to - the forbidden literal `main`, and opened its PR against `main`: + branch — its spec and stack are not yours. STOP and confirm with the user before + continuing. + - **Run this script VERBATIM** (adapt only `<slug>`) — do not re-derive, reorder, or + substitute any step: ```bash base=$(git rev-parse --abbrev-ref HEAD) # BEFORE branching — this is <base> @@ -273,7 +263,7 @@ explicitly. ("Just build it" → narrow MVP, baseline defaults, documented as as 3. **PROCEED — no permission stop.** The moment `gh pr create` returns a URL, **IMMEDIATELY begin Stage 3 Phase 1 in the same turn.** Do NOT ask the user "shall I proceed?" — the SKILL has no gate here; the next human touchpoint is the Stage 4 testing gate after - Phase 1 is built and serving. (A live run paused here un-asked and idled 72 minutes.) + Phase 1 is built and serving. ## Stage 3 — Build one phase (the loop) @@ -290,12 +280,9 @@ For the current phase (Phase 1 first; later phases on user approval): **one-line progress note** to the user: `Slice 2/5 done — <what>, gate green; next: <what>`. THEN take the next slice. -Never announce "writing all backend surfaces, prompts, and frontend in one pass" — a live -run did exactly that and shipped 11 type errors on its first write with zero test files. -Steps 5's two lines are cheap and load-bearing: a live run sat 35+ minutes with 19 dirty -files and zero commits on a stall-prone endpoint (one crash from losing it all), and its -silence drew four "are you done?" nudges from the user — each costing a full-context -re-prefill to answer. Narrate at slice boundaries and nobody has to ask. +Never write all surfaces in one pass — untested bulk writes ship broken. Step 5 is +load-bearing: committed slices survive any crash, and slice-boundary progress notes mean +the user never has to interrupt the build to ask "are you done?". Details per step: @@ -308,10 +295,13 @@ Details per step: mode has two sub-modes — pick by CONTEXT SIZE, not by habit: - **Root context lean (< ~60k tokens)** → work inline; delegation overhead isn't worth it. - **Root context heavy (> ~60k) on a NO-CACHE route** → delegate slices **sequentially** - (one worker at a time). Every inline call re-prefills the whole root conversation - (a live run re-sent 11.6M input tokens across 132 calls at ~135k each); a worker runs - the slice in a fresh ~20k context — ~7× cheaper per call, faster, and below the - large-context stall zone (live dead-streams occurred at 60k+ and 118k ctx). + (one worker at a time). Every inline call re-prefills the whole root conversation; a + worker runs the slice in a fresh small context — several times cheaper per call, + faster, and below the context sizes where free-tier streams stall. + **Pace by turns, not just tokens:** each assistant turn is one API request, and free + tiers rate-limit per minute — so do a slice's related writes (test + implementation + files) as PARALLEL tool calls in one turn instead of one file per turn. Fewer, fuller + turns get the same work done with fewer 429s. Verify each handback's CONTENT, not just its status — a worker can return `status=completed` whose body is a rate-limit error; that slice is NOT done. Each slice = its surfaces + its tests, test-first. Tell each generator exactly which files it owns; slices own disjoint paths. From d50ca6c054418fe2f224fc81b9a0c62c2fa33ebc Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 01:37:56 +0530 Subject: [PATCH 07/14] =?UTF-8?q?harness=20v0.3.1:=20429=20cool-down=20ref?= =?UTF-8?q?lex=20=E2=80=94=20sleep=2030=20+=20batch=20harder=20after=20any?= =?UTF-8?q?=20rate-limit=20mention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- harness/skills/zero-shot-build/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index bfdb114..843046e 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -302,6 +302,11 @@ Details per step: tiers rate-limit per minute — so do a slice's related writes (test + implementation files) as PARALLEL tool calls in one turn instead of one file per turn. Fewer, fuller turns get the same work done with fewer 429s. + **429 cool-down reflex:** if any tool result or retry notice mentions a rate limit + (429/"Too Many Requests"), prepend `sleep 30 && ` to your next terminal command and + combine your next several actions into one batched turn. Two rate-limit mentions within + a few minutes → also fold the next gate run and commit into a single terminal command. + Never fire rapid single-call turns into an endpoint that just rate-limited you. Verify each handback's CONTENT, not just its status — a worker can return `status=completed` whose body is a rate-limit error; that slice is NOT done. Each slice = its surfaces + its tests, test-first. Tell each generator exactly which files it owns; slices own disjoint paths. From 20fe4a0e977f03bd1bfe17487011dd8d9810e059 Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 01:44:58 +0530 Subject: [PATCH 08/14] =?UTF-8?q?restore=20main=20(v0.3)=20harness=20for?= =?UTF-8?q?=20step-3.7=20A/B=20=E2=80=94=20revert=20v0.3.1=20docs=20on=20t?= =?UTF-8?q?his=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes.md | 6 - AGENTS.md | 8 +- harness/skills/zero-shot-build/SKILL.md | 153 ++++-------------- .../references/hermes-pitfalls.md | 33 ---- 4 files changed, 38 insertions(+), 162 deletions(-) diff --git a/.hermes.md b/.hermes.md index dd52dae..f41aadd 100644 --- a/.hermes.md +++ b/.hermes.md @@ -67,12 +67,6 @@ start to finish.** The `SKILL.md` IS the process — never improvise your own bu **Routing rules:** - The outcome is ALWAYS one of the three skills above — never a fourth action, never an ad-hoc build outside a SKILL. Reading and following the chosen `SKILL.md` is mandatory. -- **A build-shaped message IS the argument.** If the user's message contains an idea - ("build/make me an agent that…"), that text **is** `$ARGUMENTS` — read the SKILL and - **start intake in this same turn**. NEVER reply by asking the user to re-submit the idea, - paste it again, or invoke the skill themselves (the skills set - `disable-model-invocation` — YOU are the only one who can start them; a live run bounced - the user's perfectly good idea back and wasted a round-trip). - **Nothing built yet** (spec still has `<!-- FILL IN -->`, bare baseline) → default to **build**. - **Genuinely ambiguous** between build / fix / sync → ask ONE short clarifying question, diff --git a/AGENTS.md b/AGENTS.md index d7a3396..d76aada 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,11 +81,9 @@ process; never improvise one): - **sync** — reconcile spec ↔ code (spec wins) → `harness/skills/zero-shot-sync/SKILL.md` The outcome is always one of these three — nothing built yet ⇒ default to **build**; -genuinely ambiguous ⇒ ask one short clarifying question first. **A build-shaped message IS -the argument: start intake in that same turn — never ask the user to re-submit the idea or -invoke the skill themselves** (`disable-model-invocation` means only you can start it). -(`.hermes.md` holds the authoritative router. The `/zero-shot-*` slash commands are optional -and require registering this clone via `skills.external_dirs`.) +genuinely ambiguous ⇒ ask one short clarifying question first. (`.hermes.md` holds the +authoritative router. The `/zero-shot-*` slash commands are optional and require registering +this clone via `skills.external_dirs`.) | Skill | Purpose | |-------|---------| diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 843046e..0142036 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -58,21 +58,6 @@ Never claim, always observe: a gate "passes" only when you ran the exact command its real output tail this session. Cheap re-verification (`py_compile` + collect-only) after mechanical edits; the full real-key gate after logic changes and before any handoff. -**Provider-hiccup protocol (free tiers 429/stall; the harness assumes NO Hermes config -changes — resilience lives here):** - -- **At build start, set the expectation in ONE line:** "If I ever stop with a provider/rate - error, just type `continue` — progress is committed as I go, nothing is lost." The user - should never have to wonder whether a dead turn ate their build. -- **On `continue` after a killed turn: resume, don't re-plan.** Re-read `git status` + - `NOTES.md`, find the current slice, and pick up exactly where it stopped. No re-reading - the whole spec, no fresh plan, no apology paragraph — one line ("resuming slice N") and - back to work. -- **Your defenses against hiccups are structural, not reactive:** batch tool calls into - fewer, fuller turns (each turn is one API request — fewer requests, fewer 429s), commit + - push every slice, and keep all state in files (spec, NOTES.md, git) so any resumed turn - can reconstruct the world from disk. - ## Stage 1 — Intake (the only interactive setup step) Intake has **two fixed sections and a variable middle**: @@ -84,24 +69,7 @@ Intake has **two fixed sections and a variable middle**: 2. **Technical round (fixed, always last)** — build-blockers only (LLM provider, stack, access method). -All rounds use `clarify`. **Five mechanics rules (numbered — follow exactly):** - -1. **BATCH each round.** Issue ALL of a round's questions as PARALLEL `clarify` calls in ONE - assistant turn — exactly one question + one flat `choices` array per call. Never one call - per turn (a 20-question intake as 20 sequential round-trips doubles intake time and - re-sends the full context each turn); never multiple questions crammed into one call. -2. **CHOICES SHAPE.** `choices` is a flat array of plain strings, 4–6 options, no nested - arrays/objects, no duplicates. A malformed entry silently drops the option — the user - never sees it. -3. **NO SKILL-TEXT LEAK.** Never copy this file's headings or bullets ("4 questions, all - multiSelect", "Tailor options to…") into user-facing question text. The user sees only - natural questions about THEIR idea. -4. **ROUND-EXIT CHECK.** Before leaving a round, count: did you ask every dimension listed - for that round (or note it as already answered)? Dropping a listed question is a failure. -5. **RECAP.** Open each round with a 1–2 sentence recap of what the prior answers - established, then the new questions. - -Three resilience rules: +All rounds use `clarify`. Three resilience rules: - **If `clarify` fails DURING a round** (mid-round timeout, empty result, unavailable), fall back to plain text for the REMAINING rounds only — ask one question at a time, wait for the @@ -192,29 +160,26 @@ spec-writer fill every capability file without a single guess. - **How will they access it?** — Web UI, CLI, REST API, scheduled job. - **One follow-up** only if something would force a mid-build pause. -**API-KEY GATE (numbered, mandatory — this is a HARD GATE, not advice):** - -1. The moment the technical round's answers land, run the `.env` validation script below via - `execute_code`/`terminal` — in THIS turn, before any spec work. -2. The script must print `PRESENT` (boolean presence) **and** `OK` (one minimal real API - call succeeded). Anything else → escalate to the user with the exact reason and re-run - after they fix `.env`. -3. **Stage 2 is INVALID until this session's transcript contains the script's `OK` output.** - -How to check `.env` (it is secret-bearing; `read_file` on it is hard-blocked — never call -it, and never ask the user to check the file themselves): run a `terminal`/`execute_code` -script that loads `.env` itself (`python-dotenv`, or `source .env` in bash) and prints ONLY -a pass/fail signal — presence as a boolean for the chosen provider's var -(`AGENT_ANTHROPIC_API_KEY`, `AGENT_GEMINI_API_KEY`, `AGENT_OPENROUTER_API_KEY`; for -**Other**, ask which env var + base URL) — never the value itself. **Use an ABSOLUTE path -to `.env`** — `execute_code` runs in its own sandbox, so a relative `dotenv_values(".env")` -reads the wrong directory and falsely reports MISSING. Resolve the repo root first -(`git rev-parse --show-toplevel` via `terminal`), then `dotenv_values(f"{repo_root}/.env")`. -Present → **validate it works** in the same script: one minimal real API call, printing -only `OK` or the error type (`401`/`429`/`model_not_found`) — a key can be present but -dead. Missing, or the test call fails → tell the user the specific reason and ask them to -fix `.env` (from `.env.example`), then re-run the check. Never echo, print, or commit a -key value. +**API key** (the only manual user step). **`.env` is a secret-bearing file — Hermes's +`read_file` tool hard-blocks it outright ("Access denied: ... secret-bearing environment +file"). Never call `read_file` on `.env` — a live run hit this and, instead of working +around it, asked the user to manually open the file and confirm, 10 minutes into intake.** +Instead run a `terminal`/`execute_code` script that loads `.env` itself (`python-dotenv`, +or `source .env` in bash) and prints ONLY a pass/fail signal — presence as a boolean for +the chosen provider's var (`AGENT_ANTHROPIC_API_KEY`, `AGENT_GEMINI_API_KEY`, +`AGENT_OPENROUTER_API_KEY`; for **Other**, ask which env var + base URL) — never the value +itself. **Use an ABSOLUTE path to `.env`, never a relative one.** `execute_code` runs the +script in its own sandboxed process, not the repo's working directory — `dotenv_values(".env")` +resolves against the sandbox and silently reports MISSING even when the key is genuinely +present in the repo (confirmed on a live run: the key was present, the relative-path script +still said MISSING). Resolve the repo root first (e.g. from a known file's path, or have the +`terminal` tool `pwd`/`git rev-parse --show-toplevel` and pass that in), then +`dotenv_values(f"{repo_root}/.env")`. Present → **validate it works** in that same script: one minimal real API call +(e.g. the provider's cheapest endpoint), printing only `OK` or the error type +(`401`/`429`/`model_not_found`) — a key can be *present but dead* (revoked account, expired +trial, dead model slug), and discovering that mid-build wastes a phase. Missing, or the test +call fails → tell the user the specific reason and ask them to fix `.env` (from +`.env.example`), then re-run the check. Never echo, print, or commit a key value. **Synthesis brief**: 2–3 paragraphs covering: what the agent does and who uses it; the interaction model (session shape, memory, multi-item); key capabilities (depth, outputs, @@ -232,81 +197,33 @@ explicitly. ("Just build it" → narrow MVP, baseline defaults, documented as as tests it). **Verify on handback**: no `<!-- FILL IN -->` left, every phase has a runnable gate, `spec/agent.md` exists if a framework is chosen. Surface its `Assumed:` flags to the user in your next message (don't wait on them). -2. **SCAFFOLD** — you own git. **First read `harness/rules/git.md`.** Then: +2. **SCAFFOLD** — you own git (`harness/rules/git.md`): - **Clean-baseline precheck (do this FIRST).** A fresh build must start from untouched boilerplate: confirm `spec/` still has `<!-- FILL IN -->` markers AND no app/agent output dir already exists. If either is already populated, you are on a PRIOR build's - branch — its spec and stack are not yours. STOP and confirm with the user before - continuing. - - **Run this script VERBATIM** (adapt only `<slug>`) — do not re-derive, reorder, or - substitute any step: - - ```bash - base=$(git rev-parse --abbrev-ref HEAD) # BEFORE branching — this is <base> - name="feature/<slug>-$(date +%Y%m%d-%H%M)-v0.1" # date-time slug = unique - git ls-remote --heads origin "$name" # must be empty; else bump timestamp - git checkout -b "$name" - printf '# Build journal — %s\n\n' "$(date -u +%FT%TZ)" > NOTES.md - # ... stage spec/ + NOTES.md explicitly, commit, push ... - git push -u origin "$name" - if [ "$base" = "$(gh repo view --json defaultBranchRef -q .defaultBranchRef.name)" ]; then - gh pr create --draft --base "$base" --head "$name" \ - --title "[build — DO NOT MERGE] <title>" # fresh clone: base IS the default - else # branch; a build must NEVER merge - gh pr create --base "$base" --head "$name" # into it, so open as DRAFT - fi - ``` + branch — STOP and confirm with the user before continuing. (A live run inherited an old + ASP.NET+MSSQL data-analyst spec this way and tried `dotnet`/Docker on a Python box.) + - `base=$(git rev-parse --abbrev-ref HEAD)` — capture `<base>` BEFORE branching; never + `git checkout main` first (you dogfood the harness version you are on). + - `name="feature/<slug>-$(date +%Y%m%d-%H%M)-v0.1"` — the date-time slug makes it unique. + Before creating it, `git ls-remote --heads origin "$name"`; if it somehow exists, bump + the timestamp. **Never `git checkout` an existing feature branch to build into** — that + imports the prior build's stack. Then `git checkout -b "$name"`. - The baseline in `src/` IS the scaffold — generators extend it in place (rename the capability slot, never copy beside it). Update `.env.example` for any new env vars. - - `NOTES.md` is the build journal (see "The build journal" below) — it goes into the - FIRST commit and gets a timestamped entry at every friction point. -3. **PROCEED — no permission stop.** The moment `gh pr create` returns a URL, **IMMEDIATELY - begin Stage 3 Phase 1 in the same turn.** Do NOT ask the user "shall I proceed?" — the - SKILL has no gate here; the next human touchpoint is the Stage 4 testing gate after - Phase 1 is built and serving. + - First commit + push, then open the PR immediately: `gh pr create --base "$base"` — + **never `--base main`**. `main` is boilerplate-only, ABSOLUTELY. ## Stage 3 — Build one phase (the loop) For the current phase (Phase 1 first; later phases on user approval): -**The slice loop is numbered — run it per slice, never "all surfaces in one pass":** - -1. Pick **ONE** slice from `spec/roadmap.md`. -2. Write that slice's **TESTS first**. -3. Implement the slice. -4. Run the slice gate (`py_compile` + `pytest --collect-only` minimum; the real gate if - logic changed). Fix until green. -5. **Commit + push the slice** (`git commit -m "phase-N: <slice>" && git push`) and post a - **one-line progress note** to the user: `Slice 2/5 done — <what>, gate green; next: - <what>`. THEN take the next slice. - -Never write all surfaces in one pass — untested bulk writes ship broken. Step 5 is -load-bearing: committed slices survive any crash, and slice-boundary progress notes mean -the user never has to interrupt the build to ask "are you done?". - -Details per step: - 1. **Read the phase's slices** from `spec/roadmap.md`. 2. **Implement each slice** via the **code-generator** role — delegate independent slices in parallel (up to 3) when `delegate_task` works AND the LLM key is a paid/dedicated one; - otherwise sequentially, one slice at a time. **Parallel fan-out on a shared/free key is - always forbidden** — it multiplies 429s on one credential pool and stalls the build - (mining the prior runs showed ~14h cumulative blocked on pool exhaustion). Sequential - mode has two sub-modes — pick by CONTEXT SIZE, not by habit: - - **Root context lean (< ~60k tokens)** → work inline; delegation overhead isn't worth it. - - **Root context heavy (> ~60k) on a NO-CACHE route** → delegate slices **sequentially** - (one worker at a time). Every inline call re-prefills the whole root conversation; a - worker runs the slice in a fresh small context — several times cheaper per call, - faster, and below the context sizes where free-tier streams stall. - **Pace by turns, not just tokens:** each assistant turn is one API request, and free - tiers rate-limit per minute — so do a slice's related writes (test + implementation - files) as PARALLEL tool calls in one turn instead of one file per turn. Fewer, fuller - turns get the same work done with fewer 429s. - **429 cool-down reflex:** if any tool result or retry notice mentions a rate limit - (429/"Too Many Requests"), prepend `sleep 30 && ` to your next terminal command and - combine your next several actions into one batched turn. Two rate-limit mentions within - a few minutes → also fold the next gate run and commit into a single terminal command. - Never fire rapid single-call turns into an endpoint that just rate-limited you. + otherwise inline, sequentially, one slice at a time. **On a shared/free key, prefer + sequential inline** — parallel fan-out multiplies 429s on one credential pool and stalls + the build (mining the prior runs showed ~14h cumulative blocked on pool exhaustion). Verify each handback's CONTENT, not just its status — a worker can return `status=completed` whose body is a rate-limit error; that slice is NOT done. Each slice = its surfaces + its tests, test-first. Tell each generator exactly which files it owns; slices own disjoint paths. diff --git a/harness/skills/zero-shot-build/references/hermes-pitfalls.md b/harness/skills/zero-shot-build/references/hermes-pitfalls.md index 436b6f7..196c05b 100644 --- a/harness/skills/zero-shot-build/references/hermes-pitfalls.md +++ b/harness/skills/zero-shot-build/references/hermes-pitfalls.md @@ -175,36 +175,3 @@ Evidence counts are from `~/.hermes/logs/agent.log*` across Jul 10–20 builds. toolchain exists (`uv`, `.venv/bin/python`); there is no `claude`/`dotnet`/`docker` unless the spec's stack installed it. Commit or stash before any `git checkout` — a dirty tree blocks the switch ("local changes would be overwritten by checkout"). - -### 21. The SERVING ROUTE matters as much as the model; and models obey gates, not prose -From a forensic comparison of step-3.7-flash vs hy3 across all Jul-20 builds: -- **Same model, different route, different build.** `stepfun-ai/step-3.7-flash` (nvidia - route): 240 s dead-stream stalls = 43% of agent-active time, ZERO prompt caching (1.87M - tokens re-prefilled in 33 min), recurring 429s, ~60 tok/s. The *identical* model as - `stepfun/step-3.7-flash` (OpenRouter route): 13/13 clean calls, 83.6% cache hit, ~100 - tok/s. Before blaming a model, check the provider/base_url in the logs — and prefer the - route with prompt caching for long agentic sessions. -- **Reliability beats latency for unattended builds.** hy3's "magic" was measured as 0 rate - limit errors across 2,613 main-loop calls + 83% clean turn completion — that's what - survives a 9.5 h overnight build. A faster model that 429s or dead-streams loses more - wall-clock than its speed saves. -- **Prose instructions get skipped; numbered gates get followed.** The same model that - ignored a prose paragraph (API-key validation) complied exactly with numbered checklists - and reacted immediately to LSP diagnostics. When a step is mandatory, write it as a - numbered HARD GATE with an explicit "invalid until X appears in the transcript" clause, - and give git/shell sequences as VERBATIM script blocks — models re-derive prose steps - badly (one invented `git rev-parse --abbrev-ref HEAD~2` and PR'd against `main`). -- **Session `model` metadata lies.** Hermes's `sessions.model` records only the LAST model - used; mid-session switches mis-attribute entire builds. Attribute work only via per-call - `API call #N: model=` log lines. -- **Locked to a no-cache route (e.g. free-tier-only)? The lever is CONTEXT DIET, not the - route.** Live measurement: 11.6M input tokens re-prefilled across 132 calls at ~135k - each; all dead-stream stalls hit on large-context requests (60k+/118k). Mitigations, in - order: (1) commit+push per slice — never let 35 min of work sit uncommitted on a - stall-prone endpoint; (2) once root context exceeds ~60k, delegate slices sequentially - into fresh ~20k worker contexts instead of building inline; (3) narrate at slice - boundaries so the user never has to ask "are you done?" — every nudge-and-answer costs a - full-context re-prefill; (4) know that Hermes's background skill-review runs IN-SESSION - on the MAIN model with the full context (observed: its own call counter at 135→150k - tokens on the same endpoint, amplifying 429s) — route it to the aux model if the config - allows, and account for it when reading rate-limit noise. From 8c3142f4faa47aeaa6713864c372375260e5b99d Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 01:54:36 +0530 Subject: [PATCH 09/14] =?UTF-8?q?harness=20v0.3.2:=20micro-trim=20over=20m?= =?UTF-8?q?ain=20=E2=80=94=20request-budget=20pacing=20+=20budgeted=20para?= =?UTF-8?q?llelism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surgical, instruction-only changes to the build SKILL (main otherwise untouched), designed for providers that cap REQUESTS per minute: - Intake: batch each round's questions as parallel clarify calls in ONE turn. - Slice rhythm: one slice ≈ 3 requests (parallel writes turn; single chained pytest+add+commit+push command; progress note). Never two terminal calls where one && chain works. 429 mention → sleep 30 prefix + batch harder. - Delegation: replace the blanket free-key inline preference with a request BUDGET — 2-3 parallel workers on disjoint slices while total request rate stays under ~half the provider cap; drop to one worker after any rate limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- harness/skills/zero-shot-build/SKILL.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 0142036..6798c64 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -69,6 +69,10 @@ Intake has **two fixed sections and a variable middle**: 2. **Technical round (fixed, always last)** — build-blockers only (LLM provider, stack, access method). +**Batch each round: issue ALL of a round's questions as PARALLEL `clarify` calls in ONE +turn** — one question + one flat `choices` array of plain strings per call; never one +question per turn, never several questions crammed into one call. + All rounds use `clarify`. Three resilience rules: - **If `clarify` fails DURING a round** (mid-round timeout, empty result, unavailable), fall @@ -219,11 +223,19 @@ explicitly. ("Just build it" → narrow MVP, baseline defaults, documented as as For the current phase (Phase 1 first; later phases on user approval): 1. **Read the phase's slices** from `spec/roadmap.md`. -2. **Implement each slice** via the **code-generator** role — delegate independent slices in - parallel (up to 3) when `delegate_task` works AND the LLM key is a paid/dedicated one; - otherwise inline, sequentially, one slice at a time. **On a shared/free key, prefer - sequential inline** — parallel fan-out multiplies 429s on one credential pool and stalls - the build (mining the prior runs showed ~14h cumulative blocked on pool exhaustion). +2. **Implement each slice** via the **code-generator** role. **Budget requests — don't fear + them.** Free tiers cap REQUESTS per minute; keep the total across the root and all + workers under roughly HALF the provider's cap, and parallelism is fine: + - `delegate_task` available → **up to 2–3 workers on independent slices in parallel**, + each on disjoint files. If any worker (or the root) hits a rate limit, drop to ONE + worker until a full slice completes clean. + - No delegation → inline, one slice at a time, in fat turns. + **Slice rhythm — one slice ≈ 3 requests:** (1) write the slice's files (tests + + implementation) as PARALLEL tool calls in ONE turn; (2) gate + ship in ONE chained + command: `pytest -q && git add <files> && git commit -m "phase-N: <slice>" && git push`; + (3) a one-line progress note. Never spend two terminal calls where one `&&` chain works; + never write one file per turn. If any tool result mentions a rate limit, prepend + `sleep 30 && ` to the next terminal command and batch harder. Verify each handback's CONTENT, not just its status — a worker can return `status=completed` whose body is a rate-limit error; that slice is NOT done. Each slice = its surfaces + its tests, test-first. Tell each generator exactly which files it owns; slices own disjoint paths. From e63f69c17a85534c7beabb2d0cef212918f8294d Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 02:25:28 +0530 Subject: [PATCH 10/14] =?UTF-8?q?harness=20v0.3.2:=20pacing=20floor=20?= =?UTF-8?q?=E2=80=94=20sleep=2015=20before=20every=20terminal=20command=20?= =?UTF-8?q?on=20free=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main harness's resilience was accidental: fat context made calls slow (~2 rpm), so the loop rarely rolled the dice during a rate-limited pool's empty windows. The trim's lean-fast calls rolled 4x more often and died 4x more often. This makes the armor deliberate: keep the trim's batching (fewer requests per unit of work) but hold the cadence at main's pace via a sleep-15 floor on terminal commands, escalating to 45s after any rate-limit mention. Steady beats fast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- harness/skills/zero-shot-build/SKILL.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 6798c64..001d9e4 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -234,8 +234,13 @@ For the current phase (Phase 1 first; later phases on user approval): implementation) as PARALLEL tool calls in ONE turn; (2) gate + ship in ONE chained command: `pytest -q && git add <files> && git commit -m "phase-N: <slice>" && git push`; (3) a one-line progress note. Never spend two terminal calls where one `&&` chain works; - never write one file per turn. If any tool result mentions a rate limit, prepend - `sleep 30 && ` to the next terminal command and batch harder. + never write one file per turn. + **Pacing floor (free-tier providers) — steady beats fast:** prepend `sleep 15 && ` to + EVERY terminal command during build stages. This deliberately holds the loop at a slow, + even cadence (~2–4 requests/min) — a long-running build that never provokes the rate + limiter outlives a fast one that dies on it. Escalation: if any tool result mentions a + rate limit (429/"Too Many Requests"), use `sleep 45 && ` for the next several commands + and batch even harder. Only drop the floor when the provider is a paid/dedicated tier. Verify each handback's CONTENT, not just its status — a worker can return `status=completed` whose body is a rate-limit error; that slice is NOT done. Each slice = its surfaces + its tests, test-first. Tell each generator exactly which files it owns; slices own disjoint paths. From ba8072e0ae8bc739bdc4066cad77a45078a503c4 Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 02:41:00 +0530 Subject: [PATCH 11/14] =?UTF-8?q?harness=20v0.3.2:=20anti-thrash=20+=20nar?= =?UTF-8?q?ration=20rules=20=E2=80=94=20nemotron-class=20behavioral=20guar?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- harness/skills/zero-shot-build/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 001d9e4..923a6c4 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -235,6 +235,12 @@ For the current phase (Phase 1 first; later phases on user approval): command: `pytest -q && git add <files> && git commit -m "phase-N: <slice>" && git push`; (3) a one-line progress note. Never spend two terminal calls where one `&&` chain works; never write one file per turn. + **Anti-thrash rule:** never re-run a failing command unchanged. If the same command + fails twice, STOP — read the full error output, then change something (the command, the + code, or your diagnosis) before any retry. Repeating an identical failing call is never + progress. + **Narrate every move:** before each tool batch, one short line — what you're doing and + why. Silence reads as a hang and makes the user interrupt a healthy build. **Pacing floor (free-tier providers) — steady beats fast:** prepend `sleep 15 && ` to EVERY terminal command during build stages. This deliberately holds the loop at a slow, even cadence (~2–4 requests/min) — a long-running build that never provokes the rate From 051eacc3b198087a9c62319f7b4d4fc7221ede9b Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 02:52:00 +0530 Subject: [PATCH 12/14] =?UTF-8?q?restore=20main=20(v0.3)=20harness=20on=20?= =?UTF-8?q?the=20build=20branch=20=E2=80=94=20pure-main=20config=20for=20t?= =?UTF-8?q?he=20nemotron=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harness/skills/zero-shot-build/SKILL.md | 33 ++++--------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/harness/skills/zero-shot-build/SKILL.md b/harness/skills/zero-shot-build/SKILL.md index 923a6c4..0142036 100644 --- a/harness/skills/zero-shot-build/SKILL.md +++ b/harness/skills/zero-shot-build/SKILL.md @@ -69,10 +69,6 @@ Intake has **two fixed sections and a variable middle**: 2. **Technical round (fixed, always last)** — build-blockers only (LLM provider, stack, access method). -**Batch each round: issue ALL of a round's questions as PARALLEL `clarify` calls in ONE -turn** — one question + one flat `choices` array of plain strings per call; never one -question per turn, never several questions crammed into one call. - All rounds use `clarify`. Three resilience rules: - **If `clarify` fails DURING a round** (mid-round timeout, empty result, unavailable), fall @@ -223,30 +219,11 @@ explicitly. ("Just build it" → narrow MVP, baseline defaults, documented as as For the current phase (Phase 1 first; later phases on user approval): 1. **Read the phase's slices** from `spec/roadmap.md`. -2. **Implement each slice** via the **code-generator** role. **Budget requests — don't fear - them.** Free tiers cap REQUESTS per minute; keep the total across the root and all - workers under roughly HALF the provider's cap, and parallelism is fine: - - `delegate_task` available → **up to 2–3 workers on independent slices in parallel**, - each on disjoint files. If any worker (or the root) hits a rate limit, drop to ONE - worker until a full slice completes clean. - - No delegation → inline, one slice at a time, in fat turns. - **Slice rhythm — one slice ≈ 3 requests:** (1) write the slice's files (tests + - implementation) as PARALLEL tool calls in ONE turn; (2) gate + ship in ONE chained - command: `pytest -q && git add <files> && git commit -m "phase-N: <slice>" && git push`; - (3) a one-line progress note. Never spend two terminal calls where one `&&` chain works; - never write one file per turn. - **Anti-thrash rule:** never re-run a failing command unchanged. If the same command - fails twice, STOP — read the full error output, then change something (the command, the - code, or your diagnosis) before any retry. Repeating an identical failing call is never - progress. - **Narrate every move:** before each tool batch, one short line — what you're doing and - why. Silence reads as a hang and makes the user interrupt a healthy build. - **Pacing floor (free-tier providers) — steady beats fast:** prepend `sleep 15 && ` to - EVERY terminal command during build stages. This deliberately holds the loop at a slow, - even cadence (~2–4 requests/min) — a long-running build that never provokes the rate - limiter outlives a fast one that dies on it. Escalation: if any tool result mentions a - rate limit (429/"Too Many Requests"), use `sleep 45 && ` for the next several commands - and batch even harder. Only drop the floor when the provider is a paid/dedicated tier. +2. **Implement each slice** via the **code-generator** role — delegate independent slices in + parallel (up to 3) when `delegate_task` works AND the LLM key is a paid/dedicated one; + otherwise inline, sequentially, one slice at a time. **On a shared/free key, prefer + sequential inline** — parallel fan-out multiplies 429s on one credential pool and stalls + the build (mining the prior runs showed ~14h cumulative blocked on pool exhaustion). Verify each handback's CONTENT, not just its status — a worker can return `status=completed` whose body is a rate-limit error; that slice is NOT done. Each slice = its surfaces + its tests, test-first. Tell each generator exactly which files it owns; slices own disjoint paths. From 099712b4a0650dd94dff1000980ca42444cd8c20 Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni <saivnm5@gmail.com> Date: Tue, 21 Jul 2026 02:56:42 +0530 Subject: [PATCH 13/14] phase-1 wip: snapshot of test fixes + service edits before final step-3.7 run --- frontend/public/app.js | 193 ++++++++++++++++++++++++----- frontend/public/index.html | 62 ++++++--- frontend/public/styles.css | 50 +++++++- pyproject.toml | 1 + src/api/csv_api.py | 28 +++-- src/api/runs.py | 42 +++++-- src/capabilities/agent.py | 18 ++- src/capabilities/csv_nodes.py | 40 +++--- src/capabilities/tasks.py | 9 +- src/domain/run.py | 10 +- src/services/sessions.py | 6 +- tests/integration/test_pipeline.py | 66 +++++----- tests/unit/test_api.py | 9 +- tests/unit/test_csv_nodes.py | 77 ++++++++++++ uv.lock | 104 ++++++++++++++++ 15 files changed, 576 insertions(+), 139 deletions(-) create mode 100644 tests/unit/test_csv_nodes.py 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 = "<tr><td colspan='1'>No data</td></tr>"; + 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 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>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 0e3be08..b3a3ac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "structlog>=24.1", "duckdb>=1.0", "pyodbc>=5.0", + "python-multipart>=0.0.9", ] [dependency-groups] diff --git a/src/api/csv_api.py b/src/api/csv_api.py index 1e4eae1..f78989b 100644 --- a/src/api/csv_api.py +++ b/src/api/csv_api.py @@ -1,7 +1,9 @@ """CSV upload and run endpoints.""" from __future__ import annotations -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from typing import List + +from fastapi import APIRouter, File, HTTPException, UploadFile from sqlalchemy.orm import Session from src.api._common import api_error, ok @@ -14,19 +16,21 @@ @router.post("/sessions") -def create_session_endpoint(session: Session = Depends(get_session)) -> dict: - row = create_session() - meta = schema_summary(row.id) +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": row.id, - "status": row.status, - "created_at": row.created_at.isoformat(), + "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, session: Session = Depends(get_session)) -> dict: +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) @@ -34,14 +38,14 @@ def get_session_endpoint(session_id: str, session: Session = Depends(get_session return ok({ "id": row.id, "status": row.status, - "created_at": row.created_at.isoformat(), - "updated_at": row.updated_at.isoformat(), + "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(...), session: Session = Depends(get_session)) -> dict: +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) @@ -59,4 +63,4 @@ async def upload_csv(session_id: str, files: list[UploadFile] = File(...), sessi "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 index 6e07f91..de87878 100644 --- a/src/capabilities/agent.py +++ b/src/capabilities/agent.py @@ -19,19 +19,25 @@ def build_agent(): graph.set_entry_point("csv_plan") graph.add_edge("csv_plan", "csv_query") - graph.add_edge("csv_query", "csv_execute") graph.add_edge("csv_execute", "csv_explain") graph.add_edge("csv_explain", "csv_finalize") - graph.add_edge("csv_error", END) 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 END - - graph.add_conditional_edges("csv_query", route_after_query, {"csv_error": "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() +agentic_ai = build_agent() \ No newline at end of file diff --git a/src/capabilities/csv_nodes.py b/src/capabilities/csv_nodes.py index 9de0c6b..f865ad5 100644 --- a/src/capabilities/csv_nodes.py +++ b/src/capabilities/csv_nodes.py @@ -1,9 +1,8 @@ """CSV Analyst graph nodes.""" from __future__ import annotations -import hashlib import json -import time +from typing import Any from src.llm.client import LLMClient, load_prompt from src.llm.providers.base import LLMError @@ -17,11 +16,12 @@ def csv_plan(state): user = json.dumps({ "question": state.get("input_text"), "schema": state.get("schema_summary"), - "history": state.get("conversation_history", [])[-8:], - "sample_size": 3, }) - out = client.complete(system, user, max_tokens=1024) - return {"plan_text": out, "error": None} + out = client.complete(system, user, max_tokens=4096) + return { + "plan_text": out, + "error": None + } except LLMError as exc: return {"error": str(exc)} @@ -36,30 +36,32 @@ def csv_query(state): "schema": state.get("schema_summary"), }) out = client.complete(system, user, max_tokens=4096) - return {"generated_code": out, "code_language": "sql", "error": None} + return { + "generated_code": out, + "code_language": "sql", + "error": None + } except LLMError as exc: return {"error": str(exc)} def csv_execute(state): try: - sql = (state.get("generated_code") or "").strip() + if not state.get("generated_code"): + return {"error": "No SQL query to execute."} session_id = state.get("session_id") - if not sql: - raise ValueError("Empty generated query.") if not session_id: - raise ValueError("Missing session_id.") - rows, latency_ms, result_hash = execute_sql(str(session_id), sql) + 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, - "source": "duckdb", - "error": None, + "error": None } except Exception as exc: - return {"error": str(exc), "generated_code": state.get("generated_code")} + return {"error": str(exc)} def csv_explain(state): @@ -69,11 +71,9 @@ def csv_explain(state): user = json.dumps({ "question": state.get("input_text"), "plan": state.get("plan_text"), - "code": state.get("generated_code"), + "schema": state.get("schema_summary"), + "query": state.get("generated_code"), "rows": (state.get("rows") or [])[:200], - "row_count": state.get("row_count"), - "latency_ms": state.get("latency_ms"), - "result_hash": state.get("result_hash"), }) out = client.complete(system, user, max_tokens=2048) return {"output_text": out, "error": None} @@ -86,4 +86,4 @@ def csv_finalize(state): def csv_error(state): - return {"status": "failed", "output_text": None, "error": state.get("error")} + return {"status": "failed", "output_text": None, "error": state.get("error")} \ No newline at end of file diff --git a/src/capabilities/tasks.py b/src/capabilities/tasks.py index 721c87d..2734b6b 100644 --- a/src/capabilities/tasks.py +++ b/src/capabilities/tasks.py @@ -3,12 +3,19 @@ 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 + return result \ No newline at end of file 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/services/sessions.py b/src/services/sessions.py index 93fc31d..fc905f8 100644 --- a/src/services/sessions.py +++ b/src/services/sessions.py @@ -31,12 +31,16 @@ def create_session() -> 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: - return db.get(SessionRow, session_id) + 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: 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" }, From b46b438355d6aae3a62450ff66c049ee2a44b11a Mon Sep 17 00:00:00 2001 From: Sai Krishna Vennamaneni Date: Tue, 21 Jul 2026 03:02:47 +0530 Subject: [PATCH 14/14] =?UTF-8?q?phase-1:=20tests=20green=20=E2=80=94=20fi?= =?UTF-8?q?x=20DuckDB=20CSV=20type=20inference=20for=20SUM/aggregation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/sessions.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/services/sessions.py b/src/services/sessions.py index fc905f8..5957dae 100644 --- a/src/services/sessions.py +++ b/src/services/sessions.py @@ -50,7 +50,34 @@ def _ingest_single(conn: Any, table: str, raw: bytes) -> int: if not rows: return 0 cols = list(rows[0].keys()) - col_defs = ", ".join(f'"{c}" TEXT' for c in cols) + + # 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(