From 19b37db947fb520710c1f623a7de0347678f744c Mon Sep 17 00:00:00 2001 From: Vivek Agarwal Date: Thu, 16 Jul 2026 19:15:43 +0530 Subject: [PATCH 1/4] phase-1: spec, alembic, FastAPI + LangGraph + mock mirror scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spec filled for all placeholder files; phase plan laid out across P1 (NL→SQL→Answer, 4 caps), P2 (history+RBAC, 3 caps), P3 (live CCTNS, 3 caps). - src/cctns_analyst/* scaffolded per project-layout. LangGraph state machine with one self-correction retry on SQL validation. Lazy FastAPI app factory (no eager SDK import). - Mock CCTNS mirror with 75 districts, ~600 seeded FIRs across 5 tables. Tiny SELECT evaluator for Phase 1 SQL subset. - pyproject.toml + alembic + 0001_initial migration. NOTE: src/ does not exist on main per latest commit message; this is the deliberate single-exception bootstrap on feature/cctns-analyst-v0.1. --- .gitignore | 3 + alembic.ini | 38 +++ alembic/env.py | 50 +++ alembic/script.py.mako | 26 ++ alembic/versions/0001_initial.py | 48 +++ pyproject.toml | 47 +++ spec/README.md | 49 +-- spec/agent.md | 303 +++++++----------- spec/api.md | 78 +++-- spec/architecture.md | 137 +++++--- spec/capabilities/answer_question.md | 44 +++ spec/capabilities/execute_bounded_query.md | 40 +++ spec/capabilities/index.md | 62 ++-- spec/capabilities/nl_to_sql.md | 43 +++ spec/capabilities/summarize_results.md | 42 +++ spec/data.md | 74 +++-- spec/roadmap.md | 191 ++++++++--- spec/ui.md | 65 ++-- src/__main__.py | 27 ++ src/cctns_analyst/__init__.py | 5 + src/cctns_analyst/api/__init__.py | 28 ++ src/cctns_analyst/api/_common.py | 20 ++ src/cctns_analyst/api/answer.py | 140 +++++++++ src/cctns_analyst/api/app_factory.py | 61 ++++ src/cctns_analyst/api/dependencies.py | 23 ++ src/cctns_analyst/api/health.py | 22 ++ src/cctns_analyst/config/__init__.py | 5 + src/cctns_analyst/config/settings.py | 57 ++++ src/cctns_analyst/db/__init__.py | 23 ++ src/cctns_analyst/db/models.py | 61 ++++ src/cctns_analyst/db/session.py | 85 +++++ src/cctns_analyst/domain/__init__.py | 6 + src/cctns_analyst/domain/answer_run.py | 16 + src/cctns_analyst/domain/question.py | 11 + src/cctns_analyst/graph/agent.py | 100 ++++++ src/cctns_analyst/graph/edges.py | 68 ++++ src/cctns_analyst/graph/nodes.py | 219 +++++++++++++ src/cctns_analyst/graph/runner.py | 111 +++++++ src/cctns_analyst/graph/state.py | 36 +++ src/cctns_analyst/llm/__init__.py | 9 + src/cctns_analyst/llm/client.py | 130 ++++++++ src/cctns_analyst/llm/providers/__init__.py | 6 + src/cctns_analyst/llm/providers/base.py | 20 ++ src/cctns_analyst/llm/providers/factory.py | 21 ++ src/cctns_analyst/llm/providers/gemini.py | 86 +++++ src/cctns_analyst/observability/__init__.py | 17 + src/cctns_analyst/observability/events.py | 85 +++++ src/cctns_analyst/prompts/loader.py | 22 ++ src/cctns_analyst/prompts/nl_to_sql.md | 42 +++ src/cctns_analyst/prompts/summarize.md | 44 +++ src/cctns_analyst/tools/__init__.py | 5 + src/cctns_analyst/tools/cctns_mirror.py | 170 ++++++++++ src/cctns_analyst/tools/mock_mirror.py | 331 ++++++++++++++++++++ 53 files changed, 3052 insertions(+), 400 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_initial.py create mode 100644 pyproject.toml create mode 100644 spec/capabilities/answer_question.md create mode 100644 spec/capabilities/execute_bounded_query.md create mode 100644 spec/capabilities/nl_to_sql.md create mode 100644 spec/capabilities/summarize_results.md create mode 100644 src/__main__.py create mode 100644 src/cctns_analyst/__init__.py create mode 100644 src/cctns_analyst/api/__init__.py create mode 100644 src/cctns_analyst/api/_common.py create mode 100644 src/cctns_analyst/api/answer.py create mode 100644 src/cctns_analyst/api/app_factory.py create mode 100644 src/cctns_analyst/api/dependencies.py create mode 100644 src/cctns_analyst/api/health.py create mode 100644 src/cctns_analyst/config/__init__.py create mode 100644 src/cctns_analyst/config/settings.py create mode 100644 src/cctns_analyst/db/__init__.py create mode 100644 src/cctns_analyst/db/models.py create mode 100644 src/cctns_analyst/db/session.py create mode 100644 src/cctns_analyst/domain/__init__.py create mode 100644 src/cctns_analyst/domain/answer_run.py create mode 100644 src/cctns_analyst/domain/question.py create mode 100644 src/cctns_analyst/graph/agent.py create mode 100644 src/cctns_analyst/graph/edges.py create mode 100644 src/cctns_analyst/graph/nodes.py create mode 100644 src/cctns_analyst/graph/runner.py create mode 100644 src/cctns_analyst/graph/state.py create mode 100644 src/cctns_analyst/llm/__init__.py create mode 100644 src/cctns_analyst/llm/client.py create mode 100644 src/cctns_analyst/llm/providers/__init__.py create mode 100644 src/cctns_analyst/llm/providers/base.py create mode 100644 src/cctns_analyst/llm/providers/factory.py create mode 100644 src/cctns_analyst/llm/providers/gemini.py create mode 100644 src/cctns_analyst/observability/__init__.py create mode 100644 src/cctns_analyst/observability/events.py create mode 100644 src/cctns_analyst/prompts/loader.py create mode 100644 src/cctns_analyst/prompts/nl_to_sql.md create mode 100644 src/cctns_analyst/prompts/summarize.md create mode 100644 src/cctns_analyst/tools/__init__.py create mode 100644 src/cctns_analyst/tools/cctns_mirror.py create mode 100644 src/cctns_analyst/tools/mock_mirror.py diff --git a/.gitignore b/.gitignore index 23af91d..d618757 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ node_modules/ .next/ dist/ build/ +frontend/out/ +playwright-report/ +test-results/ # DB *.db diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..47c4a46 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +sqlalchemy.url = sqlite:///./data/agent.db +prepend_sys_path = src + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7bc0d2a --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,50 @@ +"""Alembic env — reads APP_DATABASE_URL from settings; Base.metadata as target.""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from cctns_analyst.db.models import Base +from cctns_analyst.config.settings import Settings + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = Settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..08c478d --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -0,0 +1,48 @@ +"""initial — AnswerRun + CctnsTable + +Revision ID: 0001 +Revises: +Create Date: 2026-07-16 19:00:00 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "answer_runs", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("request_id", sa.String(), nullable=False), + sa.Column("question", sa.Text(), nullable=False), + sa.Column("sql_template", sa.Text(), nullable=False, server_default=""), + sa.Column("sql_attempts", sa.Integer(), nullable=False, server_default="0"), + sa.Column("row_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("latency_ms", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(), nullable=False, server_default="pending"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False), + ) + op.create_table( + "cctns_tables", + sa.Column("name", sa.String(), primary_key=True), + sa.Column("schema_name", sa.String(), nullable=False, server_default="cctns_mirror"), + sa.Column("columns_json", sa.Text(), nullable=False, server_default="[]"), + sa.Column("version", sa.String(), nullable=False, server_default="v1"), + sa.Column("captured_at", sa.TIMESTAMP(timezone=True), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("answer_runs") + op.drop_table("cctns_tables") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..259e469 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "cctns_analyst" +version = "0.1.0" +description = "Natural-language -> bounded SQL -> short-answer analyst over the CCTNS mirror" +requires-python = ">=3.11,<3.13" + +# Per `harness/patterns/tech-stack.md` DB driver rule: production DB driver is +# declared in [project.dependencies], never in [dependency-groups.dev]. +# `pyodbc` is the production DB driver for the live CCTNS mirror (Phase 3 +# connector); declared here even though Phase 1 uses the in-process mock by +# default. SQLite is the production DB for our *own* state (`AnswerRun`, +# `CctnsTable`). +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.8", + "pydantic-settings>=2.5", + "sqlalchemy>=2.0,<3.0", + "alembic>=1.13", + "structlog>=24.1", + "google-genai>=1.0", + "langgraph>=0.2", + "langchain-core>=0.3", + "pyodbc>=5.2", + "httpx>=0.27", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-mock>=3.14", + "playwright>=1.47", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/cctns_analyst"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" +markers = [ + "live: tests that require a real LLM key in .env", +] diff --git a/spec/README.md b/spec/README.md index 4029d7e..b980fd7 100644 --- a/spec/README.md +++ b/spec/README.md @@ -1,42 +1,17 @@ -# Spec — Single Source of Truth +# CCTNS Analyst — spec -This directory is the authoritative specification for this project. All code must match this spec. When spec and code disagree, spec wins — fix the code. +This is the spec directory for the CCTNS analyst agent being built under +`/zero-shot-build` on this repo. The spec is the source of truth — when +code and spec disagree, the spec wins (`/zero-shot-sync`). -## Status - -Check `spec/roadmap.md` to see if the spec has been filled in. If it still contains `` markers, the spec-writer sub-agent needs to complete it before any application code is written. - -## Structure - -`spec/` is **the product** — what the agent does, in terms a user can read and edit. Generic engineering doctrine (how to build anything) lives in `harness/`. +## Manifest (read in order) ``` -spec/ ← The product (you read & edit this) - roadmap.md ← Purpose, goals, success criteria, future phases - architecture.md ← System design, layers, data flow, and the chosen ## Stack - agent.md ← This agent's graph (state, nodes, edges) — if a framework is used - data.md ← Data schema - api.md ← API surface (REST/GraphQL/CLI/etc.) - ui.md ← UI requirements (if any) - capabilities/ ← One file per discrete capability - -harness/ ← How to build it (generic engineering doctrine) - rules/ ← Mandatory rules (ai-agents, git, secret-hygiene) - patterns/ ← phases, project-layout, test-driven, ui-ux, engineering-practices, - spec-driven, tech-stack (generic stack rules), code (conventions), - agentic-ai (pattern catalogue) +spec/roadmap.md ← what + how (phases, slices, gates) +spec/architecture.md ← system architecture + Stack +spec/agent.md ← LangGraph state machine (REQUIRED — LangGraph in use) +spec/capabilities/ ← one file per capability +spec/data.md ← entities we own (AnswerRun, CctnsTable) +spec/api.md ← POST /v1/answer, GET /health, static /app +spec/ui.md ← single Next.js page at /app/ ``` - -## Governance Rules - -1. **Spec first** — no code change without a spec backing it -2. **One fact, one place** — never duplicate facts across spec files; cross-reference with links -3. **Capabilities are atomic** — each file in `capabilities/` describes exactly one discrete thing the agent can do -4. **No implementation details in product spec** — `spec/` describes WHAT, `harness/` describes HOW -5. **Update spec before code** — if requirements change, update the spec first, then update the code - -## Who Updates the Spec - -- **New project:** the `/zero-shot-build` skill drives the spec-writer sub-agent, which drafts and self-reviews the spec -- **New capability:** run `/zero-shot-build` on an existing spec — it adds the capability via the spec-writer -- **Drift between spec and code:** run `/zero-shot-sync` to reconcile (spec wins) diff --git a/spec/agent.md b/spec/agent.md index 49f33fa..1089606 100644 --- a/spec/agent.md +++ b/spec/agent.md @@ -1,218 +1,147 @@ -# Agent +# Agent — CCTNS Analyst (LangGraph) -> 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. -> +> Required because LangGraph is in use. An incomplete graph is a CRITICAL +> BLOCKER. ---- +## Pattern -## Agent Architecture Pattern +State machine (linear with one self-correction retry on SQL validation +failure). Concatenation of pattern #22 (LLM-Generated Code Execution) and the +default ReAct loop (catalogued at `harness/patterns/agentic-ai.md`). - +## LLM per node -| 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. | +| Node | Provider | Model | Why | +|-----------------|----------|--------------------|--------------------------------------| +| `nl_to_sql` | Gemini | `gemini-2.5-flash` | low-latency SQL drafting | +| `summarize_answer` | Gemini | `gemini-2.5-flash` | short prose over an aggregate result | -**Chosen:** +All env-configurable via `APP_LLM_MODEL`. Secrets read by presence only via +`Settings.gemini_api_key: SecretStr`. ---- - -## LLM Provider & Model - - - -| Agent / Node | Provider | Model ID | Rationale | -|-------------|----------|----------|-----------| -| | Anthropic | | | - -**Fallback behaviour:** - -**Prompt strategy:** - ---- - -## Tools & Tool Calling - - - -| Tool name | Description | Inputs | Output | Side-effects | -|-----------|-------------|--------|--------|--------------| -| | | | | | - -**Tool selection strategy:** - -**Tool failure handling:** - ---- - -## Agent State - - +## State ```python -class AgentState(TypedDict): - # Identity - run_id: int # set at initialisation - - # Input - # ... # fields populated from the trigger - - # Pipeline data (populated progressively by nodes) - # ... - - # Output - # ... # final result fields - - # Control - error: str | None # set by any node on fatal failure - checkpoint: str | None # last completed node (for resume) +class AgentState(TypedDict, total=False): + request_id: str # uuid; threaded into logs + question: str # raw user question (length ≤ 2000) + sql: str | None # drafted SELECT (mirrors cctns_mirror.* only) + sql_attempts: int # incremented on retry; capped at 2 + columns: list[str] # column names returned by executor + rows: list[tuple] # bounded result ≤ row_cap + row_count: int # len(rows) after cap trim + error: str | None # populated on pipeline failure + latency_ms: int # wall time from request to finalise + answer: str | None # short prose summary (final deliverable) ``` ---- - -## Nodes / Steps - - - -### `node_[name]` - -**Reads from state:** - -**Writes to state:** - -**LLM call:** - -**External calls:** - -| System | Operation | On Failure | -|--------|-----------|------------| -| | | | - -**Behaviour:** +## Nodes ---- +| Node | Purpose | +|-------------------|--------------------------------------------------------------------------------------| +| `nl_to_sql` | LLM drafts a `SELECT` against `cctns_mirror` schema only (system prompt = schema). | +| `execute_sql` | Run via `CctnsMirror` with `row_cap=1000` and `statement_timeout=10 s`. | +| `validate_result` | Detect empty result / schema mismatch / disallowed statements; on fail, bump attempts.| +| `summarize_answer`| LLM turns the (≤1000) rows + question into ≤ 6-sentence prose; numeric where useful. | +| `finalize` | Persist `AnswerRun` to SQLite; emit JSON log; mark complete. | +| `handle_error` | Stuck-terminal node that records the error and emits the error JSON. | -## Graph / Flow Topology - - +## Edges ``` -START - │ - ▼ -node_a ──(error)──► node_handle_error ──► END - │ - ▼ -node_b ──(condition)──► node_c - │ │ - │ ▼ - └──────────────────► node_finalize - │ - ▼ - END + Q + ▼ + nl_to_sql ─────────────────────► handle_error (on LLM/protocol error) + │ │ + ▼ ▼ + execute_sql ──► validate_result ──► summarize_answer ──► finalize (END) + │ │ retry-once │ + ▼ ▼ ▼ + execute_sql ◄─── if attempts<2 + │ + ▼ (still failing) + handle_error ──► (END) ``` -**Conditional edges:** - -| Source node | Condition | Target | -|-------------|-----------|--------| -| | | | - ---- - -## Memory & Context - - - -| Scope | Mechanism | What is stored | -|-------|-----------|----------------| -| **Within a run** | LangGraph state | All in-progress data | -| **Across runs** | | | -| **Conversation** | | | - -**Context window management:** - ---- - -## Human-in-the-Loop Checkpoints - - - -| Checkpoint | What is shown to the user | Expected user action | Timeout / default | -|------------|--------------------------|----------------------|-------------------| -| | | | | - ---- - -## Error Handling & Recovery +Conditions are in `src/cctns_analyst/graph/edges.py`: +- `after_nl_to_sql`: empty `sql` ⇒ `handle_error`, else `execute_sql`. +- `after_execute_sql`: SQLAlchemy / pyodbc error ⇒ `handle_error`, else + `validate_result`. +- `after_validate`: invalid *and* `sql_attempts < 2` ⇒ bump attempts, return to + `nl_to_sql`. Valid ⇒ `summarize_answer`. Empty result + attempts ≥ 2 ⇒ + `summarize_answer` (the answer should say "no rows match"). +- `after_summarize`: `summarize_answer` failed ⇒ `handle_error`, else + `finalize`. - +## Memory -**Node-level:** +Stateless across requests in Phase 1. Phase 2 introduces conversation memory: +sessions and turns stored in SQLite (`Session`, `Turn` tables); graph state +hydrated from prior turns on each request. Not implemented here. -**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 +## Human-in-the-loop -**Resume / retry strategy:** +None in Phase 1. Phase 3 may add a low-confidence review gate for write-style +queries — not in scope for this build. -**Partial failure:** +## Error handling ---- +| Level | Behaviour | +|-------|------------------------------------------------------------------| +| LLM | provider 4xx/5xx ⇒ graph captures in `state["error"]` ⇒ finalize records `status=failed` | +| Tool | pyodbc transient / row-cap exceeded ⇒ graph captures same ⇒ error template (no HTTPException for UI) | +| DB | SQLite write failure ⇒ log ERROR; UI continues with degraded UX | -## Observability +## Concurrency - +- One LLM call at a time per request (sequential node execution). +- Multiple concurrent user requests run independently, each in its own session. +- The mirror's executor creates a fresh SQLAlchemy `Connection` per request + and closes it deterministically in a `try/finally` so a long-running + statement cannot block another. -| 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 | - ---- - -## Concurrency Model - - - -- **Run isolation:** -- **Parallel nodes within a run:** -- **Checkpointing:** - ---- - -## Graph Assembly (`agent/graph.py`) - - +## Graph assembly (pseudocode, ≤ 60 lines) ```python -graph = StateGraph(AgentState) - -graph.add_node("node_a", node_a) -graph.add_node("node_b", node_b) -graph.add_node("finalize", node_finalize) -graph.add_node("handle_error", node_handle_error) - -graph.set_entry_point("node_a") - -graph.add_conditional_edges( - "node_a", - lambda s: "handle_error" if s.get("error") else "node_b", +# src/cctns_analyst/graph/agent.py +from langgraph.graph import StateGraph, END +from cctns_analyst.graph.state import AgentState +from cctns_analyst.graph.nodes import ( + nl_to_sql, execute_sql, validate_result, + summarize_answer, finalize, handle_error, +) +from cctns_analyst.graph.edges import ( + after_nl_to_sql, after_execute_sql, after_validate, after_summarize, ) -graph.add_edge("node_b", "finalize") -graph.add_edge("finalize", END) -graph.add_edge("handle_error", END) - -compiled_graph = graph.compile() +def build() -> Any: + g = StateGraph(AgentState) + g.add_node("nl_to_sql", nl_to_sql) + g.add_node("execute_sql", execute_sql) + g.add_node("validate_result", validate_result) + g.add_node("summarize_answer", summarize_answer) + g.add_node("finalize", finalize) + g.add_node("handle_error", handle_error) + g.set_entry_point("nl_to_sql") + g.add_conditional_edges( + "nl_to_sql", after_nl_to_sql, + {"execute_sql": "execute_sql", "handle_error": "handle_error"}, + ) + g.add_conditional_edges( + "execute_sql", after_execute_sql, + {"validate_result": "validate_result", "handle_error": "handle_error"}, + ) + g.add_conditional_edges( + "validate_result", after_validate, + {"nl_to_sql": "nl_to_sql", "summarize_answer": "summarize_answer"}, + ) + g.add_conditional_edges( + "summarize_answer", after_summarize, + {"finalize": "finalize", "handle_error": "handle_error"}, + ) + g.add_edge("finalize", END) + g.add_edge("handle_error", END) + return g.compile() ``` + +Single compiled instance is built once per process in `runner.py`. diff --git a/spec/api.md b/spec/api.md index 442a58b..7e8e811 100644 --- a/spec/api.md +++ b/spec/api.md @@ -1,41 +1,71 @@ -# API +# API — CCTNS Analyst -> Fill in this section — see comments below. +> Either a REST surface (FastAPI) — single-port single-origin. The Next.js +> static export is served from `/app/` by the same FastAPI process on `:8001`. ---- +## Surfaces -## API Style +### `POST /v1/answer` +Single primary endpoint. - - -## Endpoints / Commands - - - -### `` +**Request body** (`application/json`): +```json +{ "question": "How many FIRs in Lucknow in the last 30 days?" } +``` -**Purpose:** +| Field | Type | Required | Constraints | +|------------|--------|----------|-----------------------------------| +| `question` | `str` | yes | length 1..2000, trimmed of whitespace | -**Request:** +**200 response** (`application/json`): ```json { - "": "" + "answer": "There have been 17 FIRs registered in Lucknow district in the last 30 days.", + "sql": "SELECT COUNT(*) AS firs FROM cctns_mirror.fir WHERE district = 'Lucknow' AND …", + "columns": ["firs"], + "rows": [[17]], + "latency_ms": 2410, + "row_count": 1, + "sql_attempts": 1 } ``` -**Response:** +**Error envelope** (validation 4xx / infra 5xx): ```json -{ - "": "" -} +{ "error": { "code": "validation_error", "message": "question is required" } } ``` -**Error cases:** -| Status | Condition | -|--------|-----------| -| 400 | | -| 500 | | +| Status | `code` (examples) | When | +|--------|--------------------------------|-----------------------------------------| +| 200 | — | success | +| 400 | `validation_error` | question missing / too long | +| 422 | `empty_question` | question is whitespace only | +| 500 | `pipeline_error` | graph reached `handle_error` | +| 503 | `mirror_unavailable` | executor connection failure | + +### `GET /health` +**200 response:** +```json +{ "status": "ok", "mirror_mode": "mock", "version": "0.1.0" } +``` + +`mirror_mode` is `"live"` when `CCTNS_MIRROR_URL` is set, else `"mock"`. +Always 200 unless the process is unable to import or initialise — in that +case the server simply doesn't bind. + +### `GET /app/*` +The Next.js static export under `frontend/out` is mounted at `/app/`. The +single-origin rule from `harness/patterns/tech-stack.md` applies — the user +runs **one** server and opens **`http://localhost:8001/app/`** (with the +trailing slash). ## Authentication - +None in Phase 1. Phase 2 introduces role-based row filtering; auth lands +there too (basic header token, then SSO). + +## Errors + +Errors are surfaced as the JSON envelope and **rendered as an error template +in the UI**, never thrown as a raw `HTTPException` JSON body to the SPA's +fetch layer (see `harness/patterns/code.md` "Pipeline Errors"). diff --git a/spec/architecture.md b/spec/architecture.md index e5e3bd9..90c18f5 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -1,68 +1,123 @@ -# Architecture - -> Fill in this section — see comments below. +# Architecture — CCTNS Analyst Agent --- ## System Overview - +The CCTNS analyst is a single-user, browser-driven natural-language → SQL → +short-answer tool. The user types a question into a Next.js UI mounted at +`/app/`, served by a FastAPI backend on `:8001`. The backend runs a LangGraph +state machine: an LLM stage drafts a bounded SELECT against the CCTNS mirror; +a SQL executor runs it under strict caps (row_cap, statement_timeout); a +validator step allows one self-correction retry if the SQL is malformed; a +summarizer turns the (≤ row_cap) rows into a short prose answer; a finalize +node records the run. Raw CCTNS row bodies never enter the LLM payload — +only schema, aggregates, or short samples are sent. + +Two data-source modes: **mock** (in-process, seeded with ≥ 500 synthetic FIR +rows across 5 tables — `fir`, `accused`, `victim`, `officer`, `district`) when +`CCTNS_MIRROR_URL` is unset; **live** (real `cctns_mirror` schema on a pyodbc +SQL Server connection) when `CCTNS_MIRROR_URL` is set. The mode is exposed via +`GET /health` and is the single flip a deployment makes. + +Observability: structured JSON logs via structlog on every request — fields +`timestamp, level, request_id, run_id, question, sql_template, latency_ms, +row_count, token_count, error`. ## Component Map - - ``` -[Component A] +[Browser] + ↓ POST /v1/answer {question} +[FastAPI :8001] ↓ -[Component B] ←→ [External Service] +[LangGraph ──► NL→SQL node ──► ExecuteSQL node ──► Validate node (1 retry) ──► Summarize node ──► Finalize] + ↓ ↑ +[LLMClient (Gemini flash)] [LLMClient (Gemini flash)] + ↓ ↑ +[Mirror: mock when CCTNS_MIRROR_URL==' else MssqlMirror via pyodbc] ────────┘ ↓ -[Component C] +[AnswerRun row written to SQLite via SQLAlchemy 2.0] ``` ## Layers - - -| Layer | Responsibility | -|-------|----------------| -| | | +| Layer | Responsibility | +|--------------------|-----------------------------------------------------------------| +| API (FastAPI) | HTTP boundary; validation; single-origin static mount of `/app` | +| Graph (LangGraph) | NL→SQL→executor→validate→summarize→finalize; one retry on fail | +| LLM (Gemini) | `LLMClient` wrapper; one call per node; `SecretStr` keys only | +| Tools | `cctns_mirror` (live) and `mock_mirror` (dev); SQL executor | +| DB (SQLAlchemy) | `AnswerRun`, `CctnsTable` (mirror-schema metadata) | +| Frontend (Next.js) | Single page `/app/`; results table; Show-SQL toggle; stubs | ## Data Flow - - -1. Trigger: -2. -3. -4. Output: +1. **Trigger:** `POST /v1/answer` from the browser. +2. **Validate:** FastAPI body schema (`{question: str, length ≤ 2000}`). +3. **Graph:** LangGraph invokes `nl_to_sql` → LLM produces a `SELECT` against + `cctns_mirror.*` from a system prompt containing only **schema** + the question. +4. **Execute:** `execute_sql` runs the SQL via `CctnsMirror` (mock or live) with + `row_cap` and `statement_timeout`. Result is ≤ 1000 rows; only schema + the + bounded result (or aggregations thereof) feed the **next** LLM step. +5. **Validate:** `validate_result` checks for empty result / shape errors; + on failure, one retry of `nl_to_sql` with the validation error in the + prompt. +6. **Summarize:** `summarize_answer` LLM call returns a short prose summary. +7. **Finalize:** write `AnswerRun` (status, latency_ms, row_count, sql_template); + emit the structured JSON log. +8. **Output:** JSON body `{answer, sql, columns, rows, latency_ms, row_count, + sql_attempts}`; the browser renders answer + table + Show-SQL toggle. ## External Dependencies - - -| Dependency | Purpose | Failure Mode | -|------------|---------|--------------| -| | | | +| Dependency | Purpose | Failure mode | +|--------------------|--------------------------------------------------|---------------------------------------------| +| Gemini API | NL→SQL + summarize (real provider, `.env` key) | 5xx → pipeline error → error template | +| CCTNS mirror (live)| `cctns_mirror` schema, SQL Server via pyodbc | down → 503 from executor → error template | +| Mock mirror (dev) | in-process synthetic data; ≥ 500 rows | n/a (deterministic; seeded once on startup) | +| SQLite (local) | `AnswerRun` audit trail | disk full → log error; UI degrades but runs | ## Stack -> This project's concrete technology choices (captured at intake, filled by the spec-writer). The generic, every-project rules — model-naming, DB driver, dev port, test environment — live in `harness/patterns/tech-stack.md`; this section is only what **this** project picked. - -- **Language:** -- **Agent framework:** -- **LLM provider + model:** -- **Backend:** -- **Database + ORM:** -- **Frontend:** -- **Dependency management:** - -| Key library | Version | Purpose | -|-------------|---------|---------| -| | | | - -**Avoid:** +- **Language:** Python 3.11 +- **Agent framework:** LangGraph (state machines with conditional retry) +- **LLM provider + model:** Gemini (`gemini-2.5-flash`, configurable via + `APP_LLM_MODEL`) +- **Backend:** FastAPI (uvicorn) +- **Database + ORM:** SQLAlchemy 2.0 with `text()` escape hatch; SQLite for + our own state; SQLAlchemy-2.0 mapped types only +- **Mirror:** SQLAlchemy + `pyodbc` SQL Server reachable from `CCTNS_MIRROR_URL` + (prod); in-process `MockMirror` (dev) +- **Frontend:** Next.js 15 + React 19 + Tailwind v4 (`output:'export'`, + `basePath:'/app'`, served by FastAPI at `/app/`) +- **Dependency management:** uv + `pyproject.toml` +- **Observability:** structlog (JSON to stdout) + +| Key library | Version | Purpose | +|------------------------------------|----------|-------------------------------| +| fastapi | ^0.115 | HTTP | +| uvicorn[standard] | ^0.32 | ASGI | +| langgraph | ^0.2 | State machine | +| langchain-core | ^0.3 | LLM plumbing | +| google-genai | ≥ 1.0 | Gemini provider | +| pyodbc | ^5.2 | SQL Server mirror driver | +| sqlalchemy | ≥ 2.0 | ORM + raw SQL | +| pydantic + pydantic-settings | ≥ 2.8 | Settings + body validation | +| structlog | ≥ 24.1 | Structured logs | +| pytest + httpx + testclient | latest | Test suite | +| playwright + @playwright/test | latest | E2E (chromium) | + +**Avoid:** +- A hardcoded op-list mapping questions to canned queries (the agentic-ai pattern + catalogue explicitly forbids this — pattern #22). +- SQLite-as-substitute-for-MSSQL tests where the gate claims production fidelity + — the mock mirror IS the dev/prod default and is documented as such. +- LangSmith / OpenTelemetry in Phase 1. ## Deployment Model - +Long-running local service. Single binary: `uv run python -m src` exposes +FastAPI on `0.0.0.0:8001`, mounts the Next.js static export from +`frontend/out` at `/app/`, exposes JSON APIs under `/v1/*`. Default port is +8001 per `tech-stack.md`; overridable via the `PORT` env var. diff --git a/spec/capabilities/answer_question.md b/spec/capabilities/answer_question.md new file mode 100644 index 0000000..e517b30 --- /dev/null +++ b/spec/capabilities/answer_question.md @@ -0,0 +1,44 @@ +# Capability: `answer_question` + +## What It Does +Owns the primary user journey end-to-end: a single natural-language question +in, JSON answer out. + +## Inputs + +| Input | Type | Source | Required | +|------------|--------|-------------------------|----------| +| `question` | `str` | `POST /v1/answer` body | yes | + +## Outputs + +| Output | Type | Destination | +|-----------------|--------|-------------------| +| `answer` | `str` | JSON response | +| `sql` | `str` | JSON response | +| `columns` | `list[str]` | JSON response | +| `rows` | `list[tuple]` (`≤ ~100` rendered; the bounded full set in payload) | JSON response | +| `latency_ms` | `int` | JSON response | +| `row_count` | `int` | JSON response | +| `sql_attempts` | `int` | JSON response | + +## External Calls + +Composes `nl_to_sql`, `execute_bounded_query`, and `summarize_results` via +LangGraph (see `spec/agent.md`). + +## Business Rules + +- One question maps to one bounded run; status is recorded in `AnswerRun`. +- Errors surface as `{error: {code, message}}` envelope with a 4xx (validation) + or 5xx (infra) status — never a raw stack trace; the UI renders an error + template, never an `HTTPException` JSON body. + +## Success Criteria + +- [ ] `POST /v1/answer` returns 200 with a valid JSON body when given a + well-formed question that matches some row in the mirror. +- [ ] `GET /health` returns `{status:"ok", mirror_mode:"mock"|"live"}`. +- [ ] On query failure (timeout / SQL error), the response is the error + envelope, status 5xx; the persisted `AnswerRun.status = "failed"`. +- [ ] End-to-end latency p50 ≤ 6 s against the mock mirror (real Gemini key). diff --git a/spec/capabilities/execute_bounded_query.md b/spec/capabilities/execute_bounded_query.md new file mode 100644 index 0000000..10ea83a --- /dev/null +++ b/spec/capabilities/execute_bounded_query.md @@ -0,0 +1,40 @@ +# Capability: `execute_bounded_query` + +## What It Does +Runs a single read-only `SELECT` on the mirror under strict caps. + +## Inputs + +| Input | Type | Source | Required | +|---------|--------|-----------------------|----------| +| `sql` | `str` | `state["sql"]` | yes | + +## Outputs + +| Output | Type | Destination | +|-------------|------------------|----------------------| +| `columns` | `list[str]` | `state["columns"]` | +| `rows` | `list[tuple]` | `state["rows"]` | +| `row_count` | `int` | `state["row_count"]` | + +## External Calls + +| System | Operation | On Failure | +|----------------------|-----------------|-------------------------------------------| +| `CctnsMirror.execute(sql, row_cap, statement_timeout_ms)` | one SQL run | bubble up ⇒ `handle_error` | + +## Business Rules + +- `row_cap = 1000`, `statement_timeout = 10 s`, hard-coded defaults + overridable via env (`APP_ROW_CAP`, `APP_STATEMENT_TIMEOUT_MS`). +- Result is **trimmed** to `row_cap` rows server-side; the LLM never sees + more. +- One statement per request. Multiple statements forbidden. + +## Success Criteria + +- [ ] A query that returns 5,000 rows gets trimmed to 1,000. +- [ ] A query that exceeds the statement timeout raises ⇒ `state["error"]` + set by the dispatcher; the graph does not crash. +- [ ] Resulting `columns` length matches the number of projected fields in + `sql`. diff --git a/spec/capabilities/index.md b/spec/capabilities/index.md index 7455bda..c07feca 100644 --- a/spec/capabilities/index.md +++ b/spec/capabilities/index.md @@ -1,38 +1,24 @@ -# Capabilities Index - -> **Boilerplate status:** The spec-writer sub-agent creates one file per capability in this directory. Each file describes exactly one discrete thing the agent can do. - ---- - -## 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) +# Capabilities — CCTNS Analyst + +A capability is one cohesive unit of behaviour. Each `spec/capabilities/*.md` +file describes one capability using the standard template. The list below is +the canonical index — keep it current. + +## Capability index + +- [Natural language → SQL](./nl_to_sql.md) — LLM drafts a bounded SELECT. +- [Execute bounded query](./execute_bounded_query.md) — Row-cap + timeout. +- [Summarize results](./summarize_results.md) — Prose summary from rows. +- [Answer question](./answer_question.md) — Orchestrator; owns the primary + user journey end-to-end. + +Phase 2 (planned, defined in `spec/roadmap.md`): +- Conversation memory (session-scoped turns). +- Role-based row-level filter. +- History sidebar. + +Phase 3 (planned): +- Live CCTNS connector (`MssqlMirror` via pyodbc). +- Token-bucket rate limit. +- Audit log every read. +- Switch-to-live panel wiring. diff --git a/spec/capabilities/nl_to_sql.md b/spec/capabilities/nl_to_sql.md new file mode 100644 index 0000000..6e5f90f --- /dev/null +++ b/spec/capabilities/nl_to_sql.md @@ -0,0 +1,43 @@ +# Capability: `nl_to_sql` + +## What It Does +Turns a natural-language analyst question into a single read-only `SELECT` +against the `cctns_mirror` schema. + +## Inputs + +| Input | Type | Source | Required | +|------------|--------|-------------------------|----------| +| `question` | `str` | caller (`POST /v1/answer`) | yes | +| `schema` | `dict` | runtime introspection of `CctnsMirror.list_tables()` | yes | + +## Outputs + +| Output | Type | Destination | +|----------------|--------|----------------------------| +| `sql` | `str` | `state["sql"]` | +| `sql_attempts` | `int` | `state["sql_attempts"]` (= 1 here) | + +## External Calls + +| System | Operation | On Failure | +|--------|-------------------|----------------------------------| +| Gemini | one chat completion (`gemini-2.5-flash`) | bubble up ⇒ `handle_error` | + +## Business Rules + +- The output SQL must reference **only** the `cctns_mirror` schema (no joins, + no CTEs, no DDL, no DML). +- Schema for the prompt comes from `CctnsMirror.list_tables()` — **never** + includes row data (data-locality block rule). +- The prompt must use *one* LLM call per invocation; no inner retries here + (the outer graph has the single retry-once). + +## Success Criteria + +- [ ] Given a valid question, returns a single `SELECT … FROM cctns_mirror.*` + string. +- [ ] The LLM payload for the call contains **only schema**, not raw rows + (prompt-spy test asserts this). +- [ ] Given a malformed protocol response, `state["error"]` is set; the + node never raises into the graph loop. diff --git a/spec/capabilities/summarize_results.md b/spec/capabilities/summarize_results.md new file mode 100644 index 0000000..5ac0759 --- /dev/null +++ b/spec/capabilities/summarize_results.md @@ -0,0 +1,42 @@ +# Capability: `summarize_results` + +## What It Does +Turns the bounded result rows + the original question into a short prose +answer (≤ 6 sentences). + +## Inputs + +| Input | Type | Source | Required | +|--------------|-----------------|--------------------|----------| +| `question` | `str` | `state["question"]`| yes | +| `columns` | `list[str]` | `state["columns"]` | yes | +| `rows` | `list[tuple]` | `state["rows"]` | yes | +| `row_count` | `int` | `state["row_count"]` | yes | + +## Outputs + +| Output | Type | Destination | +|-----------|--------|----------------------| +| `answer` | `str` | `state["answer"]` | + +## External Calls + +| System | Operation | On Failure | +|--------|-------------------|----------------------------------| +| Gemini | one chat completion (`gemini-2.5-flash`) | bubble up ⇒ `handle_error` | + +## Business Rules + +- Answer is ≤ 6 sentences; one LLM call; no inner retries. +- The summary should mention the row count and a numeric head if the data + is numeric. +- No raw row bodies enter the LLM payload — schema + ≤ 100 sample rows + + aggregates only (data-locality block rule). + +## Success Criteria + +- [ ] Returns a non-empty string of ≤ ~ 600 chars for a small bounded + result. +- [ ] The LLM payload contains no raw rows; only schema, sample (≤ 100), + or aggregates. +- [ ] Empty result ⇒ the answer contains "no rows" or equivalent. diff --git a/spec/data.md b/spec/data.md index e331007..0f89b9b 100644 --- a/spec/data.md +++ b/spec/data.md @@ -1,34 +1,70 @@ -# Data Model +# Data Model — CCTNS Analyst -> Fill in this section — see comments below. +> We do **not** persist raw CCTNS rows. The mirror holds them. Our DB holds +> only audit/run metadata. ---- +## Entities -## Storage Technology +### `AnswerRun` +One row per `POST /v1/answer` call. - +| Field | Type | Notes | +|----------------|--------------|---------------------------------------------------| +| `id` | `str` (uuid) | primary key | +| `request_id` | `str` (uuid) | matches the in-process `state["request_id"]` | +| `question` | `str` | length ≤ 2000 | +| `sql_template` | `str` | the SELECT ran (empty if failed before execution) | +| `sql_attempts` | `int` | 1 or 2 | +| `row_count` | `int` | 0 … row_cap | +| `latency_ms` | `int` | ≤ statement_timeout + LLM time | +| `status` | `enum` | `pending` / `completed` / `failed` | +| `error_message`| `str|None` | | +| `created_at` | `timestamp` | UTC; default `now()` | +| `updated_at` | `timestamp` | UTC; `onupdate=now()` | -## Entities +### `Question` +Intentionally **not** a separate table — questions are stored inline in +`AnswerRun`. (No PII separation needed in Phase 1; questions are operational +payloads, not user accounts.) + +### `CctnsTable` +Mirror schema metadata, version-stamped. One row per logical table discovered +on the mirror. Holds column names + types only — never row data. - +| Field | Type | Notes | +|---------------|--------------|------------------------------------------------------| +| `name` | `str` | primary key (the logical table name) | +| `schema_name` | `str` | e.g. `cctns_mirror` | +| `columns_json`| `str` | JSON list of `{name, type}` | +| `version` | `str` | bumped when mirror metadata changes; used for cache | +| `captured_at` | `timestamp` | UTC | -### Entity: +## Relationships - +``` +AnswerRun ──────(by request_id)─►──►── matching future runs (Phase 2) +``` -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| id | | yes | Primary key | -| | | | | +(Phase 2 will add `Session` + `Turn` tables for conversation memory; not in +Phase 1.) -### Relationships +## Lifecycle - +- `AnswerRun`: created at graph entry (`status=pending`), updated at finalize + (`completed` or `failed`). Never mutated thereafter; audit-trail. +- `CctnsTable`: refreshed when the mirror's schema introspection reports a + new version (Phase 3 connector). -## Data Lifecycle +## PII / Sensitivity - +- Questions may contain case-identifying terms ("Lucknow district", "fir + 2024-0012") — treat as **sensitive**. We do not send the question text to + any external telemetry, only to the LLM provider; logs include the + question only on infra-level errors (Phase 3 audit log will gate this + precisely). +- No row payloads ever persisted. Schema metadata only. -## Sensitive Data +## Storage - +- SQLite at `APP_DATABASE_URL=sqlite:///./data/agent.db` for `AnswerRun` and + `CctnsTable`. Migrations via Alembic (`alembic upgrade head`). diff --git a/spec/roadmap.md b/spec/roadmap.md index 03ae242..20002d3 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -1,62 +1,181 @@ -# Roadmap +# Roadmap — CCTNS Analyst Agent -> Fill in each section. Run `/zero-shot-build [your idea]` to have it filled automatically. +> A natural-language → read-only-SQL → short-answer analyst over the **CCTNS mirror**, +> tuned for low latency and low load on the source database. Built for UP Police +> analysts. --- ## What This Agent Does - +The CCTNS analyst turns a free-form analyst question (e.g. *"How many FIRs in +Lucknow in the last 30 days?"*) into a single read-only SQL query against the +**CCTNS mirror** (a denormalized, bounded copy of the source CCTNS MsSQL +database, living under the `cctns_mirror` schema), executes it within strict +bounds, and returns a short prose answer with the SQL it ran and a small table +of rows. It never returns raw rows to the LLM — only schema + aggregates — to +keep regulated CCTNS data within the production trust boundary. ## Who Uses It - +UP Police analysts and investigating officers who need quick, ad-hoc numbers +from CCTNS without paying the cost of a SQL query against the live operational +database. ## Core Problem Being Solved - +Live CCTNS is read-sensitive and load-sensitive: ad-hoc analyst queries cost +DBAs' time and can spike load on the operational system. A separate mirrored +read-replica, queried through a bounded agent, keeps analyst productivity high +without taxing the operational DB. ## Success Criteria - - -- [ ] -- [ ] -- [ ] +- [ ] P50 end-to-end latency from POST to prose answer ≤ 6 s against the mock + mirror; ≤ 10 s against the live mirror. +- [ ] 100 % of LLM payloads contain schema, aggregates, or ≤ N sample rows + only — **no** raw row bodies (data-locality test asserts this verbatim). +- [ ] Every executed SQL is bounded: row_cap ≤ 1000, statement_timeout ≤ 10 s. +- [ ] The mock mirror holds ≥ 500 synthetic FIRs across ≥ 5 tables; the + gate test asserts a value only computable from the **full** dataset. +- [ ] The analyst can ask, see a short answer, expand "Show SQL", and see a + latency badge — end-to-end — against the real Gemini key, no console + errors. ## What This Agent Does NOT Do (Out of Scope) - +- **Writes** — never mutates CCTNS, the mirror, or our own DB. +- **Cross-database joins** beyond the mirror's denormalized shape. +- **Any** tool other than SQL on the mirror. +- Real-time push to Slack / dashboards (declared in Phase 2/3). +- Multi-user / RBAC (Phase 2). +- Live CCTNS production connector (Phase 3). ## Key Constraints - - -## 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. +- **Latency:** p50 ≤ 6 s on mock, ≤ 10 s on live. One LLM call per request on + the primary path (no streaming re-prompts); a one-time self-correction retry + if the SQL validator rejects the first pass. +- **Privacy:** raw rows MUST NOT enter any LLM payload; data-locality is a + BLOCK-level check enforced by a prompt-spy test. +- **DB load:** row_cap=1000, statement_timeout=10 s; one statement per request; + no concurrent RUN batch on the same connection. +- **Compliance:** every read against the live mirror is audit-logged with + user, question, and sql_template (Phase 3). +- **Stack:** Python 3.11 + FastAPI + LangGraph + SQLAlchemy 2.0 with raw-SQL + escape hatch; Gemini `gemini-2.5-flash` (configurable via `APP_LLM_MODEL`); + Next.js 15 + React 19 + Tailwind v4 static export at `/app/`. -### 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):** +## Phases of Development -### Phase 2 — +### Phase 1 — Single-shot NL → SQL → Answer (smallest user-testable win) -- **Goal:** +- **Goal:** A UP Police analyst can paste a natural-language question into the + web UI, get back a short prose answer + the SQL the system ran + a small + results table, with a latency badge. - **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 — graph+LLM) — `src/cctns_analyst/graph/*`, + `src/cctns_analyst/llm/*`, `src/cctns_analyst/prompts/*.md`. deps: none. + - `slice-b` (backend — DB+mock-mirror) — `src/cctns_analyst/db/*`, + `src/cctns_analyst/tools/{cctns_mirror,mock_mirror}.py`, seed script. + deps: none (the prompt's schema lookup is at runtime; slice-b publishes + `list_tables()` and `columns_for()` regardless of slice-a being there). + - `slice-c` (backend — API+config+observability) — + `src/cctns_analyst/api/*`, `src/cctns_analyst/domain/*`, + `src/cctns_analyst/config/*`, `src/cctns_analyst/observability/*`, + `src/cctns_analyst/{__init__,__main__}.py`. deps: declared on + `slice-a` (graph compose) and `slice-b` (mirror references in + `answer.py`); therefore slice-c **must** ship after `slice-a`+`slice-b` + land. + - `slice-d` (frontend) — `frontend/*`, `tests/e2e/smoke.spec.ts`. deps: + declared on `slice-c` (the UI calls `/v1/answer`); therefore slice-d + ships after `slice-c`. +- **Key surfaces / files:** + - slice-a: `src/cctns_analyst/graph/{state,nodes,edges,agent,runner}.py`, + `src/cctns_analyst/llm/{client,providers/{base,factory,gemini}}.py`, + `src/cctns_analyst/prompts/{nl_to_sql,summarize}.md`. + - slice-b: `src/cctns_analyst/db/{__init__,models,session}.py`, + `src/cctns_analyst/tools/{cctns_mirror,mock_mirror}.py`, + `scripts/seed_mock_mirror.py`. + - slice-c: `src/cctns_analyst/api/{__init__,_common,health,answer}.py`, + `src/cctns_analyst/domain/{question,answer_run}.py`, + `src/cctns_analyst/config/settings.py`, + `src/cctns_analyst/observability/events.py`, + `src/cctns_analyst/{__init__,__main__}.py`. + - slice-d: `frontend/{package.json,next.config.mjs,postcss.config.mjs,src/app/{globals.css,layout.tsx,page.tsx,error.tsx}}`, + `tests/e2e/smoke.spec.ts`. +- **Gate command (run from project root `E:\smalltech\hermes\zero-shot-hermes-harness`):** + ```bash + uv sync + uv run alembic upgrade head && uv run alembic current + uv run pytest -q + cd frontend && npm install --silent && npm run build && cd .. + ! grep -r '@tailwind' frontend/out + grep -rE '\.(flex|grid|bg-|rounded-|text-)' frontend/out/_next/static/css/*.css | head + npx playwright install --with-deps chromium && npx playwright test tests/e2e/ --reporter=line + uv run python -m src & # boot smoke on :8001, then kill + ``` +- **How the user tests it (handoff seed):** + - The root session launches the server. + - User opens `http://localhost:8001/app/`. + - User types a question (e.g. *"How many FIRs in Lucknow in the last 30 days?"*), + clicks **Ask**. + - User observes: short prose answer, a results table (≤ ~100 rows shown), + "Show SQL" toggle revealing the SQL, latency badge; **Loading** state while + in flight; **Error** template on a deliberately broken question (e.g. + `"?"`). + - Clearly-labelled **stubs** for: follow-up input (Phase 3), + conversation history sidebar (Phase 2), switch-to-live-CCTNS panel + (Phase 3), multi-user / role-filter panel (Phase 2). Each stub says + "Coming in Phase 2/3" — never "broken" or "error". + +### Phase 2 — Multi-turn + history + role-based filtering + +- **Goal:** Conversation memory, per-role row-level filter policy, and a + history sidebar in the UI. +- **Independent slices (parallel build units):** + - `slice-p2a` (backend — conversation memory) — + `src/cctns_analyst/db/models.py` adds `Session`, `Turn`; new + `src/cctns_analyst/graph/memory.py`. deps: none. + - `slice-p2b` (backend — role-based row filtering) — new + `src/cctns_analyst/tools/row_filter.py`, prompt augmentation in + `prompts/nl_to_sql.md`. deps: none. + - `slice-p2c` (frontend — history sidebar) — `frontend/src/app/page.tsx` + history panel, new `frontend/src/components/HistorySidebar.tsx`. + deps: declared on slice-p2a. +- **Key surfaces / files:** as above. +- **Gate command:** + ```bash + uv run pytest -q + cd frontend && npm run build && cd .. + npx playwright test tests/e2e/ --reporter=line + ``` +- **How the user tests it:** asks a follow-up referencing earlier turns; + toggles a role selector (e.g. *Investigating Officer*) and re-asks; + sees prior turns in the sidebar that survive a page reload. + +### Phase 3 — Real CCTNS connector + production hardening + +- **Goal:** Live (read-only) connector to the production mirror, token-bucket + rate limit, audit log every read against production. +- **Independent slices (parallel build units):** + - `slice-p3a` (backend — live connector) — + `src/cctns_analyst/tools/cctns_mirror.py` adds `MssqlMirror` + (pyodbc) keyed off `CCTNS_MIRROR_URL`. deps: none. + - `slice-p3b` (backend — rate limit + audit log) — + `src/cctns_analyst/observability/audit.py`, + `src/cctns_analyst/tools/limiter.py`. deps: none. + - `slice-p3c` (frontend — switch-to-live panel) — + `frontend/src/app/page.tsx` panel wired to a `POST /v1/mirror-mode`. + deps: declared on slice-p3a. +- **Key surfaces / files:** as above. +- **Gate command:** production-DB connection from `.env`; full E2E live. + ```bash + uv run pytest -q -m live + cd frontend && npm run build && cd .. + npx playwright test tests/e2e/ --reporter=line + ``` +- **How the user tests it:** flips a switch in the UI; asks a question; the + audit-log endpoint returns the read; latency stays within the p50 bar. diff --git a/spec/ui.md b/spec/ui.md index 15219c3..da32689 100644 --- a/spec/ui.md +++ b/spec/ui.md @@ -1,32 +1,61 @@ -# UI +# UI — CCTNS Analyst -> **Boilerplate status:** Delete this file if the agent has no UI. Otherwise, filled in by the spec-writer sub-agent. +> Web dashboard. Single page served at `/app/` by FastAPI. ---- +## Frames -## UI Type +### Primary page — `frontend/src/app/page.tsx` - +A single screen with three regions: -## Views / Screens +| Region | Contents | +|------------------|-----------------------------------------------------------------| +| **Header** | Brand mark · "CCTNS Analyst" · mirror-mode badge (`mock`/`live`)| +| **Composer** | Question `