From 816f22c227e32bfb7328a6cfcda0c955b4bec9dd Mon Sep 17 00:00:00 2001 From: ahmed mohammed Date: Tue, 22 Sep 2026 07:44:01 +0300 Subject: [PATCH] feat: analyst proposes multiple options; README with gitea remote Co-Authored-By: Claude Opus 4.7 --- README.md | 107 +++++++++++++++++++++++++++++++++--------- lib/analyst.ts | 58 +++++++++++++++++++++++ tests/analyst.test.ts | 45 ++++++++++++++++++ 3 files changed, 189 insertions(+), 21 deletions(-) create mode 100644 lib/analyst.ts create mode 100644 tests/analyst.test.ts diff --git a/README.md b/README.md index e215bc4..ee4bdcf 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,101 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# SQL Server qAI -## Getting Started +Paste a SQL Server ShowPlan XML (or drop a `.sqlplan`), get a report with several ranked remediation options and +rewritten queries. Two models, one job: a **generative analyst model** proposes the options and writes the report, and +**Jev** (TypeSafe System One) scores every option and decides which one to run first. Jev never writes SQL; it returns +typed scores, a choice, and confidences. The report renders as OpenUI elements, not a markdown blob. -First, run the development server: +## Pipeline + +1. `lib/digest.ts` parses the XML in code: top operators, missing indexes, warnings, waits, estimate-vs-actual row + errors, tables, statement text, subtree cost, parallelism. Raw XML never reaches a model. +2. The analyst model receives the digest JSON and proposes 2–4 genuinely different options (index, rewrite, statistics, + parallelism, schema), each with ordered action steps and an optional rewrite. +3. Jev answers four questions per option (`bottleneck_fit`, `semantic_safety`, `ease` `score`; `root_cause` `noul`) and + one cross-option call (`first_to_run` `choice`, `anything_worth_running` `noul`). +4. `lib/jev.ts` combines them with visible weights (0.40 / 0.30 / 0.15 / 0.15 — four numbers in that file, the only + tuning knob) and prints a verdict. Jev's pick leads only when its confidence clears 0.5; otherwise the report says + the order came from composite scoring. +5. The analyst streams the final report as OpenUI Lang; `` paints it as it arrives. + +Getting the XML: SSMS → **Ctrl+M** (include actual execution plan) → run the query → right-click the Execution Plan tab +→ *Save As .sqlplan*. No SSMS: prefix the batch with `SET STATISTICS XML ON;` and copy the XML result cell. + +## Run it + +This repo runs as a plain Node process. Containerization is written (`Dockerfile`, Task 12 of the plan) but is not +required for local work — `npm run dev` is the normal path on a small machine. ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +npm install +cp .env.example .env.local # then fill in APP_SECRET and DATA_DIR +npm run dev # http://localhost:3000 ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +`.env.local`: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +| key | why | +| --- | --- | +| `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. | +| `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. | -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user +brings their own endpoint and their own spend. Blank key field = keep the stored key. -## Learn More +- Analyst: any OpenAI-compatible base URL + key + model, plus optional extra JSON params. + A local endpoint such as `http://192.168.88.131:8888/v1` works; use key `EMPTY` when the server has no auth. +- Jev: a TypeSafe API key from `console.typesafe.ai/keys`, model usually `jev-latest`. -To learn more about Next.js, take a look at the following resources: +## Tests -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +```bash +npm test # node --test on tests/*.test.ts, no test framework installed +npm run build # production build + type check +npm run e2e # Playwright: signup, settings, real analyst + real Jev run, copy, print, history, 4 widths +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network. +`npm run e2e` is the opposite — it drives the real app against the real models, and reads `JEV_API_KEY` from +`.env.local`. Expect it to be slow; that is the models, not the harness. -## Deploy on Vercel +`tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the +skip becomes the real check on ShowPlan attribute spellings. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Git remote (Gitea) -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Origin points at the self-hosted Gitea: + +```bash +git remote -v # http://192.168.88.39:3000/ahmed/sql-server-query-ai.git +git push origin feat/sql-server-qai +``` + +The remote URL deliberately contains **no password**. Git prompts for the username (`ahmed`) and password the first +time, or you can keep them out of prompts: + +```bash +git config credential.helper osxkeychain # remember them locally +git push origin feat/sql-server-qai +``` + +Never commit the password into `.git/config`, a remote URL, or this README — a stored credential in the repo is +readable by whoever you hand the repo to. If you want it non-interactive in CI, create a Gitea token +(Settings → Applications) and use `http://192.168.88.39:3000/ahmed/sql-server-query-ai.git` with the token as password. + +## Layout + +``` +lib/digest.ts XML → bounded digest (the only shape models see) +lib/analyst.ts propose 2–4 options; stream the final OpenUI report +lib/jev.ts questions, weights, ranking, verdict, fallback +lib/report-ui.tsx OpenUI component library — the same module builds the prompt and renders the answer +lib/db.ts node:sqlite: users, sessions, settings, analyses +lib/vault.ts AES-256-GCM seal/open/mask for credentials +lib/auth.ts scrypt passwords, hashed session tokens (DB stores the SHA-256, never the token) +app/api/analyze SSE pipeline: digesting → proposing → judging → writing +tests/ offline unit suites tests-e2e/ Playwright +docs/superpowers/ spec + the 13-task implementation plan this was built from +``` + +Design: dark terminal console, JetBrains Mono for data, IBM Plex Sans for prose, one phosphor accent. +Print stylesheet flips to paper white and drops the rail, buttons and chrome — that is the Export to PDF path. diff --git a/lib/analyst.ts b/lib/analyst.ts new file mode 100644 index 0000000..009935d --- /dev/null +++ b/lib/analyst.ts @@ -0,0 +1,58 @@ +import { z } from "zod/v4"; +import { digestForModel, type Digest } from "./digest.ts"; +import type { AnalystConfig } from "./settings.ts"; + +export class AnalystError extends Error {} + +const Action = z.object({ title: z.string().min(3), detail: z.string().min(3), + effort: z.enum(["low", "medium", "high"]), requires_change_control: z.boolean() }); +const RawCandidate = z.object({ key: z.string().min(2), title: z.string().min(3), diagnosis: z.string().min(15), + actions: z.array(Action).min(1), rewrite_sql: z.string().nullable(), expected: z.string().min(5) }); +const Proposal = z.object({ candidates: z.array(RawCandidate).min(2).max(4) }); +export type Action = z.infer; +export type Candidate = z.infer; + +export const CANDIDATE_SYSTEM_PROMPT = `You are a SQL Server performance engineer. You receive a JSON digest of one +execution plan. Judge only what the digest contains — never invent operators, waits, or row counts you were not given. + +Return 2 to 4 genuinely different remediation options, not one option restated. Real differences: an index change, a +rewritten query, a statistics or parallelism fix, a batching or schema change. At least one option must be the +lowest-risk one. Each option needs a snake_case key, a diagnosis naming WHY the plan is slow, 1-4 ordered actions, an +optional rewritten query, and an expected effect stated as a concrete claim about the plan (logical reads, seek vs +scan, tempdb spill, degree of parallelism). + +A rewrite must return the same rows as the original. If you cannot rewrite safely, set rewrite_sql to null. +Output ONE JSON object and nothing else: +{"candidates":[{"key":"idx_covering_orders","title":"...","diagnosis":"...","actions":[{"title":"...","detail":"...", +"effort":"low|medium|high","requires_change_control":false}],"rewrite_sql":null,"expected":"..."}]}`; + +export function extractJson(text: string): unknown { + const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text); + const s = (fenced ? fenced[1] : text).trim(); + const start = s.indexOf("{"); const end = s.lastIndexOf("}"); + if (start === -1 || end <= start) throw new AnalystError("The analyst model returned prose instead of JSON."); + try { return JSON.parse(s.slice(start, end + 1)); } + catch { throw new AnalystError("The analyst model returned malformed JSON."); } +} + +export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string, + fetchImpl: typeof fetch = fetch): Promise { + const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` }, + body: JSON.stringify({ model: cfg.model, temperature: 0.6, ...cfg.extra, stream: false, + messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT }, + { role: "user", content: JSON.stringify({ digest: digestForModel(digest), user_note: note || null }) }] }), + }).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); }); + + if (!res.ok) throw new AnalystError(`Analyst model returned HTTP ${res.status}. ${(await res.text()).slice(0, 300)}`); + const content: string = ((await res.json() as any)?.choices?.[0]?.message?.content) ?? ""; + const parsed = Proposal.safeParse(extractJson(content)); + if (!parsed.success) { + const n = (extractJson(content) as any)?.candidates?.length ?? 0; + throw new AnalystError(n < 2 + ? `The analyst model proposed ${n} option(s); at least 2 are needed before Jev can rank anything.` + : `Analyst output did not match the schema at ${parsed.error.issues[0].path.join(".")}: ${parsed.error.issues[0].message}`); + } + return parsed.data.candidates; +} diff --git a/tests/analyst.test.ts b/tests/analyst.test.ts new file mode 100644 index 0000000..6d44d40 --- /dev/null +++ b/tests/analyst.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +const { proposeCandidates, extractJson, AnalystError } = await import("../lib/analyst.ts"); + +const cfg = { baseUrl: "https://api.test/v1", apiKey: "k", model: "m", extra: {} }; +const digest = { sql: "SELECT 1", bytes: 8, subtreeCost: 1, parallel: false, nonParallelReason: null, + earlyAbort: null, tables: ["t"], topOperators: [], rowGuessErrors: [], missingIndexes: [], warnings: [], waits: [] } as any; +const ok = (content: string) => ({ ok: true, status: 200, + json: async () => ({ choices: [{ message: { content } }] }) }); +const CANDS = { candidates: [ + { key: "idx_customer", title: "Covering index", diagnosis: "Scan of 1.2M rows on Sales.Orders, no key on CustomerId.", + actions: [{ title: "Create index", detail: "CREATE INDEX ...", effort: "medium", requires_change_control: true }], + rewrite_sql: null, expected: "seek instead of scan" }, + { key: "rewrite_split", title: "Split the statement", diagnosis: "One statement scans everything then filters.", + actions: [{ title: "Deploy rewrite", detail: "Two statements", effort: "high", requires_change_control: true }], + rewrite_sql: "SELECT ...", expected: "far fewer logical reads" }] }; + +test("returns schema-valid candidates", async () => { + const got = await proposeCandidates(cfg, digest, "", async () => ok(JSON.stringify(CANDS)) as any); + assert.equal(got.length, 2); + assert.equal(got[0].key, "idx_customer"); +}); +test("survives code fences and sends extra params", async () => { + let sent: any; + const fetchImpl = async (_u: any, init: any) => { sent = JSON.parse(init.body); + return ok("```json\n" + JSON.stringify(CANDS) + "\n```") as any; }; + const got = await proposeCandidates({ ...cfg, extra: { temperature: 0.1 } }, digest, "index exists already", fetchImpl as any); + assert.equal(got.length, 2); + assert.equal(sent.temperature, 0.1); + assert.match(sent.messages[0].content, /candidates/); + assert.match(sent.messages[1].content, /index exists already/); +}); +test("names the stage on auth, prose, and shape failures", async () => { + await assert.rejects(() => proposeCandidates(cfg, digest, "", + async () => ({ ok: false, status: 401, text: async () => "bad key" }) as any), + (e: any) => e instanceof AnalystError && /401/.test(e.message)); + await assert.rejects(() => proposeCandidates(cfg, digest, "", async () => ok("no json here") as any), AnalystError); + await assert.rejects(() => proposeCandidates(cfg, digest, "", + async () => ok(JSON.stringify({ candidates: [CANDS.candidates[0]] })) as any), + (e: any) => /at least 2/.test(e.message)); +}); +test("extractJson copes with fences and chatter", () => { + assert.deepEqual(extractJson('Sure!\n```json\n{"a":1}\n```'), { a: 1 }); + assert.deepEqual(extractJson('noise {"a":1} noise'), { a: 1 }); +});